diff --git a/.github/img/logo.png b/.github/img/logo.png index e99200c92d..f2decdf850 100644 Binary files a/.github/img/logo.png and b/.github/img/logo.png differ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1576701947..c54a8320b3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,10 @@ name: Linting -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: jobs: lint: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 31b13de04f..588679c00c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,10 @@ name: Unit Tests -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: jobs: test: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index e1763b4c7e..56179bca2c 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -1,6 +1,10 @@ name: Type checking -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: jobs: typecheck: diff --git a/.gitignore b/.gitignore index 9c8333dbe5..3297e1de07 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,12 @@ *__pycache__ *.DS_store *.swp +*.swo build/pdf-intermediate.md pyhamilton/LAY-BACKUP .ipynb_checkpoints *.egg-info *.log build/lib -venv/ -venv/* -venv\ + +myenv \ No newline at end of file diff --git a/.pylintrc b/.pylintrc index 545f187eb4..523deffdf4 100644 --- a/.pylintrc +++ b/.pylintrc @@ -148,7 +148,8 @@ disable=abstract-method, zip-builtin-not-iterating, missing-module-docstring, # added missing-function-docstring, # added - C0103 # added, invalid module name + C0103, # added, invalid module name + too-many-positional-arguments, # backends take many arguments [REPORTS] diff --git a/.vscode/settings.json b/.vscode/settings.json index e2c8781d5e..b5e3e68556 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,8 +5,11 @@ "agrow", "agrowpumps", "coro", + "decalibrate", "Defaultable", + "Deprecated", "frontmost", + "hepa", "Inheco", "iswap", "jsonify", diff --git a/CHANGELOG.md b/CHANGELOG.md index 914a3e7cf2..35a6640da3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Merge `height_functions.py` and `volume_functions.py` into `height_volume_functions.py` (https://github.com/PyLabRobot/pylabrobot/pull/200) - Type checking for `lh.pick_up_tips`, `lh.drop_tips`, `lh.aspirate`, and `lh.dispense` and 96-channel versions. - `ChatterBoxBackend` outputs are now pretty (https://github.com/PyLabRobot/pylabrobot/pull/208) +- `liquid_height` now defaults to 0 instead of 1 (https://github.com/PyLabRobot/pylabrobot/pull/205/) +- `material_z_thickness` of a `Container` is used in computing its bottom (https://github.com/PyLabRobot/pylabrobot/pull/205/) +- Default `pickup_distance_from_top` in `LiquidHandler.{move_plate,move_lid}` were lowered by 3.33 (https://github.com/PyLabRobot/pylabrobot/pull/205/) +- `PlateCarrierSite` can now take `ResourceStack` as a child, as long as the children are `Plate`s (https://github.com/PyLabRobot/pylabrobot/pull/226) +- `Resource.get_size_{x,y,z}` now return the size of the resource in local space, not absolute space (https://github.com/PyLabRobot/pylabrobot/pull/235) +- `Resource.center` now returns the center of the resource in local space, not absolute space (https://github.com/PyLabRobot/pylabrobot/pull/235) +- Rename `ChatterBoxBackend` to `LiquidHandlerChatterboxBackend` (https://github.com/PyLabRobot/pylabrobot/pull/242) +- Move `LiquidHandlerChatterboxBackend` from `liquid_handling.backends.chatterbox_backend` to `liquid_handling.backends.chatterbox` (https://github.com/PyLabRobot/pylabrobot/pull/242) +- Changed `pedestal_size_z=-5` to `pedestal_size_z=-4.74` for `PLT_CAR_L5AC_A00` (https://github.com/PyLabRobot/pylabrobot/pull/255) +- rename `homogenization_` parameters in `STAR` to `mix_` (https://github.com/PyLabRobot/pylabrobot/pull/261) +- Lids no longer get special treatment when assigned to a ResourceStack. Assign them to a plate directly (https://github.com/PyLabRobot/pylabrobot/pull/267) ### Added @@ -47,6 +58,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `adapter_hole_size_z` and `plate_z_offset` parameters to `PlateAdapter` (https://github.com/PyLabRobot/pylabrobot/pull/215) - `wide_high_volume_tip_with_filter` and `HTF_L_WIDE` (https://github.com/PyLabRobot/pylabrobot/pull/222) - Serialize code cells and closures (https://github.com/PyLabRobot/pylabrobot/pull/220) +- `Container.get_anchor()` now supports `"cavity_bottom"` as an argument for `z` (https://github.com/PyLabRobot/pylabrobot/pull/205/) +- `pylabrobot.resources.utils.query` for basic querying (https://github.com/PyLabRobot/pylabrobot/commit/4a07f6a32a9a33d0370eb9c29015567c98aea002) +- `HamiltonLiquidHandler.allow_firmware_planning` to allow STAR/Vantage to plan complex liquid handling operations automatically (may break hardware agnosticity unexpectedly) (https://github.com/PyLabRobot/pylabrobot/pull/224) +- `size_z` and `nesting_z_height` for `Cor_96_wellplate_360ul_Fb_Lid` (https://github.com/PyLabRobot/pylabrobot/pull/226) +- `NestedTipRack` (https://github.com/PyLabRobot/pylabrobot/pull/228) +- `HTF_L_ULTRAWIDE`, `ultrawide_high_volume_tip_with_filter` (https://github.com/PyLabRobot/pylabrobot/pull/229/) +- `get_absolute_size_x`, `get_absolute_size_y`, `get_absolute_size_z` for `Resource` (https://github.com/PyLabRobot/pylabrobot/pull/235) +- `Cytation5Backend` for plate reading on BioTek Cytation 5 (https://github.com/PyLabRobot/pylabrobot/pull/238) +- More chatterboxes (https://github.com/PyLabRobot/pylabrobot/pull/242) + - `FanChatterboxBackend` + - `PlateReaderChatterboxBackend` + - `PowderDispenserChatterboxBackend` + - `PumpChatterboxBackend` + - `PumpArrayChatterboxBackend` + - `ScaleChatterboxBackend` + - `ShakerChatterboxBackend` + - `TemperatureControllerChatterboxBackend` +- Add fluorescence reading to Cytation 5 (https://github.com/PyLabRobot/pylabrobot/pull/244) +- Add `F.linear_tip_spot_generator` and `F.randomized_tip_spot_generator` for looping over tip spots, with caching (https://github.com/PyLabRobot/pylabrobot/pull/256) +- Add `skip_autoload`, `skip_iswap`, and `skip_core96_head` flags to `STAR.setup` (https://github.com/PyLabRobot/pylabrobot/pull/263) +- Add `skip_autoload`, `skip_iswap`, and `skip_core96_head` flags to `Vantage.setup` (https://github.com/PyLabRobot/pylabrobot/pull/263) ### Deprecated @@ -54,6 +86,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Passing single values to LiquidHandler `pick_up_tips`, `drop_tips`, `aspirate`, and `dispense` methods. These methods now require a list of values. - `utils.positions`: `string_to_position`, `string_to_index`, `string_to_indices`, `string_to_pattern`. - `ThermoScientific_96_DWP_1200ul_Rd` in favor of `Thermo_TS_96_wellplate_1200ul_Rb` (https://github.com/PyLabRobot/pylabrobot/pull/215) +- `Azenta4titudeFrameStar_96_wellplate_skirted` in favor of `Azenta4titudeFrameStar_96_wellplate_200ul_Vb` (https://github.com/PyLabRobot/pylabrobot/pull/205/) +- `Cos_96_DWP_2mL_Vb` in favor of `Cos_96_wellplate_2mL_Vb (https://github.com/PyLabRobot/pylabrobot/pull/205/)` ### Fixed @@ -62,9 +96,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Fix Opentrons backend resource definitions: Opentrons takes well locations as ccc instead of lfb - Fix ThermoScientific_96_DWP_1200ul_Rd to ThermoScientific_96_wellplate_1200ul_Rd (https://github.com/PyLabRobot/pylabrobot/pull/183). - `libusb_package` is now an optional dependency. +- Plates with a skirt are now correctly lowered when placed on plate carriers with a pedestal (https://github.com/PyLabRobot/pylabrobot/pull/205/) +- `minimum_height` in `STAR` and `Vantage` now correctly refer to a `Container`s bottom instead of being a function of liquid height (https://github.com/PyLabRobot/pylabrobot/pull/205/) +- `aspirate96` and `dispense96` type check +- fix angles computed by grip directions (https://github.com/PyLabRobot/pylabrobot/pull/234) +- picking up rotated resources in `STAR` (https://github.com/PyLabRobot/pylabrobot/pull/233) +- picking up rotated resources in `Vantage` (https://github.com/PyLabRobot/pylabrobot/pull/268) +- assigning rotated resources to `PlateReader` now have the correct location (https://github.com/PyLabRobot/pylabrobot/pull/233) +- use local sizes in computing anchor (https://github.com/PyLabRobot/pylabrobot/pull/233) +- don't raise a blow out air volume error when requesting 0, or when volume tracking is disabled (https://github.com/PyLabRobot/pylabrobot/pull/262) +- fix get_child_location for resources rotated by 180 degrees (https://github.com/PyLabRobot/pylabrobot/pull/269) +- volume tracking on channel 1-n (https://github.com/PyLabRobot/pylabrobot/pull/273) ### Removed - HamiltonDeck.load_from_lay_file - `hamilton_parse` module and the VENUS labware database parser. - `PLT_CAR_L4_SHAKER` was removed in favor of `MFX_CAR_L5_base` (https://github.com/PyLabRobot/pylabrobot/pull/188/). +- `items`, `num_items_x` and `num_items_y` attributes of `ItemizedResource` (https://github.com/PyLabRobot/pylabrobot/pull/231) +- `report` is no longer a parameter of `PlateReader.read_absorbance` (default is now OD) (https://github.com/PyLabRobot/pylabrobot/pull/238) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8f263d42f..3d87b081a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ See the installation instructions [here](docs/installation.md). For contributing If this is your first time contributing to open source, check out [How to Open Source](./docs/how-to-open-source.md) for an easy introduction. -It's highly appreciated by the PyLabRobot developers if you communicate what you want to work on, to minimize any duplicate work. You can do this on the [forum](https://forums.pylabrobot.org/c/pylabrobot-development/23). +It's highly appreciated by the PyLabRobot developers if you communicate what you want to work on, to minimize any duplicate work. You can do this on the [forum](https://discuss.pylabrobot.org). ## Development Tips @@ -71,4 +71,4 @@ Backends are the primary objects used to communicate with hardware. If you want ## Support -If you have any questions, feel free to reach out using the [PyLabRobot forum](https://forums.pylabrobot.org). +If you have any questions, feel free to reach out using the [PyLabRobot forum](https://discuss.pylabrobot.org). diff --git a/Makefile b/Makefile index 1fb5123e17..a1381abedc 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,17 @@ endif .PHONY: docs lint test docs: - sphinx-build -b html docs docs/build/ -j 1 -W + sphinx-build -b html docs docs/build/ -j 16 -W + +docs-fast: + echo "building docs without api for speed" + sphinx-build -t no-api -b html docs docs/build/ -j 16 -W clean-docs: rm -rf docs/build rm -rf docs/_autosummary + rm -rf docs/jupyter_execute + rm -rf docs/user_guide/jupyter_execute lint: $(BIN)python -m pylint pylabrobot diff --git a/README.md b/README.md index 5de8b69d9c..589c1c1737 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,31 @@
-

PyLabRobot

-[**Docs**](https://docs.pylabrobot.org) | [**Forum**](https://forums.pylabrobot.org) | [**Installation**](https://docs.pylabrobot.org/installation.html) | [**Getting started**](https://docs.pylabrobot.org/basic.html) +
+Docs | +Forum | +Installation | +Getting started +
## What is PyLabRobot? -PyLabRobot is a hardware agnostic, pure Python library for liquid handling robots and other lab automation equipment. Read [the paper]() in Device. +PyLabRobot is a hardware agnostic, pure Python library for liquid handling robots, plate readers, pumps, scales, heater shakers, and other lab automation equipment. Read [the paper]() in Device. + +Advantages over proprietary software: -### Liquid handling robots +- **Cross-platform**: PyLabRobot works on Windows, macOS, and Linux. Many other interfaces are Windows-only. +- **Universal**: PyLabRobot works with any supported liquid handling robot, plate reader, pump, scale, heater shaker, etc. through a single interface. +- **Fast iteration**: PyLabRobot enables rapid development of protocols using atomic commands run interactively in Jupyter notebooks or the Python REPL. This decreases iteration time from minutes to seconds. +- **Open-source**: PyLabRobot is open-source and free to use. +- **Control**: With Python, you have ultimate flexibility to control your lab automation equipment. You can write Turing-complete protocols that include feedback loops. +- **Modern**: PyLabRobot is built on modern Python 3.8+ features and async/await syntax. +- **Fast support**: PyLabRobot has [an active community forum](https://discuss.pylabrobot.org) for support and discussion, and most pull requests are merged within a day. -PyLabRobot provides a layer of general-purpose abstractions over robot functions, with various device drivers for communicating with different kinds of robots. Right now we only have drivers for Hamilton, Tecan and Opentrons liquid handling robots, but we will soon have drivers for many more. The Hamiton and Tecan backends provide an interactive firmware interface that works on Windows, macOS and Linux. The Opentrons driver is based on the [Opentrons HTTP API](https://github.com/rickwierenga/opentrons-python-api). We also provide a browser-based Visualizer which can visualize the state of the deck during a run, and testing backends which do not require access to a robot. +### Liquid handling robots ([docs](https://docs.pylabrobot.org/basic.html)) + +PyLabRobot enables the use of any liquid handling robot through a single universal interface, that works on any modern operating system (Windows, macOS, Linux). We currently support Hamilton STAR, Hamilton Vantage, Tecan Freedom EVO, and Opentrons OT-2 robots, but we will soon support many more. Here's a quick example showing how to move 100uL of liquid from well A1 to A2 using firmware on **Hamilton STAR** (this will work on any operating system!): @@ -30,40 +44,34 @@ await lh.dispense(lh.deck.get_resource("plate")["A2"], vols=100) await lh.return_tips() ``` -To run the same procedure on an **Opentrons**, change the following lines: - -```diff -- from pylabrobot.liquid_handling.backends import STAR -+ from pylabrobot.liquid_handling.backends import OpentronsBackend - -- deck = Deck.load_from_json_file("hamilton-layout.json") -+ deck = Deck.load_from_json_file("opentrons-layout.json") +To run the same protocol on an **Opentrons**, use the following: -- lh = LiquidHandler(backend=STAR(), deck=deck) -+ lh = LiquidHandler(backend=OpentronsBackend(host="x.x.x.x"), deck=deck) +```python +from pylabrobot.liquid_handling.backends import OpentronsBackend +deck = Deck.load_from_json_file("opentrons-layout.json") +lh = LiquidHandler(backend=OpentronsBackend(host="x.x.x.x"), deck=deck) ``` Or **Tecan** (also works on any operating system!): -```diff -- from pylabrobot.liquid_handling.backends import STAR -+ from pylabrobot.liquid_handling.backends import EVO +```python +from pylabrobot.liquid_handling.backends import EVO +deck = Deck.load_from_json_file("tecan-layout.json") +lh = LiquidHandler(backend=EVO(), deck=deck) +``` -- deck = Deck.load_from_json_file("hamilton-layout.json") -+ deck = Deck.load_from_json_file("tecan-layout.json") +We also provide a browser-based Visualizer which can visualize the state of the deck during a run, and can be used to develop and test protocols without a physical robot. -- lh = LiquidHandler(backend=STAR(), deck=deck) -+ lh = LiquidHandler(backend=EVO(), deck=deck) -``` +![Visualizer](.github/img/visualizer.png) -### Plate readers +### Plate readers ([docs](https://docs.pylabrobot.org/plate_reading.html)) -PyLabRobot also provides a layer of general-purpose abstractions for plate readers, currently with just a driver for the ClarioStar. This driver works on Windows, macOS and Linux. Here's a quick example showing how to read a plate using the ClarioStar: +Moving a plate to a ClarioStar using a liquid handler, and reading luminescence: ```python from pylabrobot.plate_reading import PlateReader, ClarioStar -pr = PlateReader(name="plate reader", backend=ClarioStar()) +pr = PlateReader(name="plate reader", backend=ClarioStar(), size_x=1, size_y=1, size_z=1) await pr.setup() # Use in combination with a liquid handler @@ -73,6 +81,75 @@ lh.move_plate(lh.deck.get_resource("plate"), pr) data = await pr.read_luminescence() ``` +For Cytation5, use the `Cytation5` backend. + +### Centrifuges + +Centrifugation at 800g for 60 seconds: + +```python +from pylabrobot.centrifuge import Centrifuge, VSpin +cf = Centrifuge(name = 'centrifuge', backend = VSpin(bucket_1_position=0), size_x= 1, size_y=1, size_z=1) +await cf.setup() + +await cf.start_spin_cycle(g = 800, duration = 60) +``` + +### Pumps ([docs](https://docs.pylabrobot.org/pumps.html)) + +Pumping at 100 rpm for 30 seconds using a Masterflex pump: + +```python +from pylabrobot.pumps import Pump +from pylabrobot.pumps.cole_parmer.masterflex import Masterflex + +p = Pump(backend=Masterflex()) +await p.setup() +await p.run_for_duration(speed=100, duration=30) +``` + +### Scales ([docs](https://docs.pylabrobot.org/scales.html)) + +Taking a measurement from a Mettler Toledo scale: + +```python +from pylabrobot.scales import Scale +from pylabrobot.scales.mettler_toledo import MettlerToledoWXS205SDU + +backend = MettlerToledoWXS205SDU(port="/dev/cu.usbserial-110") +scale = Scale(backend=backend, size_x=0, size_y=0, size_z=0) +await scale.setup() + +weight = await scale.get_weight() +``` + +### Heater shakers ([docs](https://docs.pylabrobot.org/heater_shakers.html)) + +Setting the temperature of a heater shaker to 37°C: + +```python +from pylabrobot.heating_shaking import HeaterShaker +from pylabrobot.heating_shaking import InhecoThermoShake + +backend = InhecoThermoShake() +hs = HeaterShaker(backend=backend, name="HeaterShaker", size_x=0, size_y=0, size_z=0) +await hs.setup() +await hs.set_temperature(37) +``` + +### Fans ([docs](https://docs.pylabrobot.org/fans.html)) + +Running a fan at 100% intensity for one minute: + +```python +from pylabrobot.only_fans import Fan +from pylabrobot.only_fans import HamiltonHepaFan + +fan = Fan(backend=HamiltonHepaFan(), name="my fan") +await fan.setup() +await fan.turn_on(intensity=100, duration=60) +``` + ## Resources ### Documentation @@ -86,7 +163,7 @@ data = await pr.read_luminescence() ### Support -- [forums.pylabrobot.org](https://forums.pylabrobot.org) for questions and discussions. +- [discuss.pylabrobot.org](https://discuss.pylabrobot.org) for questions and discussions. - [GitHub Issues](https://github.com/pylabrobot/pylabrobot/issues) for bug reports and feature requests. ## Citing diff --git a/docs/_static/logo.png b/docs/_static/logo.png index 0b4d039360..50794315ba 100644 Binary files a/docs/_static/logo.png and b/docs/_static/logo.png differ diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst index 71903d8364..38b3ba0e5b 100644 --- a/docs/_templates/autosummary/class.rst +++ b/docs/_templates/autosummary/class.rst @@ -11,7 +11,7 @@ .. autosummary:: :toctree: . {% for item in attributes %} - ~{{ fullname }}.{{ item }} + ~{{ objname }}.{{ item }} {%- endfor %} {% endif %} {% endblock %} @@ -23,7 +23,7 @@ .. autosummary:: :toctree: . {% for item in methods %} - ~{{ fullname }}.{{ item }} + ~{{ objname }}.{{ item }} {%- endfor %} {% endif %} {% endblock %} diff --git a/docs/api/pylabrobot.config.rst b/docs/api/pylabrobot.config.rst new file mode 100644 index 0000000000..0f6eb23ec4 --- /dev/null +++ b/docs/api/pylabrobot.config.rst @@ -0,0 +1,21 @@ +.. currentmodule:: pylabrobot.config + +pylabrobot.config package +========================= + +This package contains APIs for configuring PLR. More information in :doc:`configuration tutorial `. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + config.Config + io.ConfigReader + io.ConfigWriter + io.file.FileReader + io.file.FileWriter + formats.json_config.JsonLoader + formats.json_config.JsonSaver + formats.ini_config.IniLoader + formats.ini_config.IniSaver diff --git a/docs/pylabrobot.heating_shaking.rst b/docs/api/pylabrobot.heating_shaking.rst similarity index 75% rename from docs/pylabrobot.heating_shaking.rst rename to docs/api/pylabrobot.heating_shaking.rst index 0a4cd9e14d..1319f3efb6 100644 --- a/docs/pylabrobot.heating_shaking.rst +++ b/docs/api/pylabrobot.heating_shaking.rst @@ -10,7 +10,7 @@ This package contains APIs for working with heater shakers. :nosignatures: :recursive: - pylabrobot.heating_shaking.heater_shaker.HeaterShaker + heater_shaker.HeaterShaker Backends @@ -21,4 +21,5 @@ Backends :nosignatures: :recursive: - pylabrobot.heating_shaking.inheco.InhecoThermoShake + chatterbox.HeaterShakerChatterboxBackend + inheco.InhecoThermoShake diff --git a/docs/pylabrobot.liquid_handling.backends.rst b/docs/api/pylabrobot.liquid_handling.backends.rst similarity index 50% rename from docs/pylabrobot.liquid_handling.backends.rst rename to docs/api/pylabrobot.liquid_handling.backends.rst index 4f88c8914f..49b896c3fd 100644 --- a/docs/pylabrobot.liquid_handling.backends.rst +++ b/docs/api/pylabrobot.liquid_handling.backends.rst @@ -1,4 +1,4 @@ -.. currentmodule:: pylabrobot.liquid_handling +.. currentmodule:: pylabrobot.liquid_handling pylabrobot.liquid_handling.backends package =========================================== @@ -13,9 +13,8 @@ Abstract :nosignatures: :recursive: - pylabrobot.liquid_handling.backends.backend.LiquidHandlerBackend - pylabrobot.liquid_handling.backends.serializing_backend.SerializingBackend - pylabrobot.liquid_handling.backends.USBBackend.USBBackend + backends.backend.LiquidHandlerBackend + backends.serializing_backend.SerializingBackend Hardware -------- @@ -25,11 +24,11 @@ Hardware :nosignatures: :recursive: - pylabrobot.liquid_handling.backends.hamilton.base.HamiltonLiquidHandler - pylabrobot.liquid_handling.backends.hamilton.STAR.STAR - pylabrobot.liquid_handling.backends.hamilton.vantage.Vantage - pylabrobot.liquid_handling.backends.opentrons_backend.OpentronsBackend - pylabrobot.liquid_handling.backends.tecan.EVO.EVO + backends.hamilton.base.HamiltonLiquidHandler + backends.hamilton.STAR.STAR + backends.hamilton.vantage.Vantage + backends.opentrons_backend.OpentronsBackend + backends.tecan.EVO.EVO Net --- @@ -41,8 +40,8 @@ Net backends can be used to communicate with servers that manage liquid handling :nosignatures: :recursive: - pylabrobot.liquid_handling.backends.http.HTTPBackend - pylabrobot.liquid_handling.backends.websocket.WebSocketBackend + backends.http.HTTPBackend + backends.websocket.WebSocketBackend Testing @@ -53,4 +52,4 @@ Testing :nosignatures: :recursive: - pylabrobot.liquid_handling.backends.chatterbox_backend.ChatterBoxBackend + backends.chatterbox.LiquidHandlerChatterboxBackend diff --git a/docs/pylabrobot.liquid_handling.rst b/docs/api/pylabrobot.liquid_handling.rst similarity index 81% rename from docs/pylabrobot.liquid_handling.rst rename to docs/api/pylabrobot.liquid_handling.rst index 63fcd1854b..f0e89e83ef 100644 --- a/docs/pylabrobot.liquid_handling.rst +++ b/docs/api/pylabrobot.liquid_handling.rst @@ -1,10 +1,10 @@ -.. currentmodule:: pylabrobot.liquid_handling +.. currentmodule:: pylabrobot.liquid_handling pylabrobot.liquid_handling package ================================== This package contains all APIs relevant to liquid handling. -See :ref:`Basic liquid handling ` for a simple example. +.. See :doc:`Basic liquid handling ` for a simple example. Machine control is split into two parts: backends and front ends. Backends are used to control the machine, and front ends are used to interact with the backend. Front ends are designed to be @@ -16,7 +16,7 @@ be run on practically all supported hardware. :nosignatures: :recursive: - pylabrobot.liquid_handling.liquid_handler.LiquidHandler + liquid_handler.LiquidHandler Backends @@ -39,7 +39,7 @@ Operations are the main data holders used to transmit information from the liqui :nosignatures: :recursive: - pylabrobot.liquid_handling.standard + standard Strictness diff --git a/docs/pylabrobot.liquid_handling.strictness.rst b/docs/api/pylabrobot.liquid_handling.strictness.rst similarity index 100% rename from docs/pylabrobot.liquid_handling.strictness.rst rename to docs/api/pylabrobot.liquid_handling.strictness.rst diff --git a/docs/pylabrobot.machine.rst b/docs/api/pylabrobot.machine.rst similarity index 57% rename from docs/pylabrobot.machine.rst rename to docs/api/pylabrobot.machine.rst index c68fa1dc05..e02dbdd37b 100644 --- a/docs/pylabrobot.machine.rst +++ b/docs/api/pylabrobot.machine.rst @@ -1,4 +1,4 @@ -.. currentmodule:: pylabrobot.machine +.. currentmodule:: pylabrobot.machines Machine is a backend'd Resource. Check out the contributing section for more information. @@ -7,5 +7,6 @@ Machine is a backend'd Resource. Check out the contributing section for more inf :nosignatures: :recursive: - pylabrobot.machine.Machine - pylabrobot.machine.MachineBackend + machine.Machine + backends.machine.MachineBackend + backends.usb.USBBackend diff --git a/docs/api/pylabrobot.only_fans.rst b/docs/api/pylabrobot.only_fans.rst new file mode 100644 index 0000000000..8b184a5f72 --- /dev/null +++ b/docs/api/pylabrobot.only_fans.rst @@ -0,0 +1,26 @@ +.. currentmodule:: pylabrobot.only_fans + +pylabrobot.only_fans package +================================ + +This package contains APIs just for fans. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + fan.Fan + + +Backends +-------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + backend.FanBackend + chatterbox.FanChatterboxBackend + hamilton_hepa_fan.HamiltonHepaFan diff --git a/docs/pylabrobot.plate_reading.rst b/docs/api/pylabrobot.plate_reading.rst similarity index 76% rename from docs/pylabrobot.plate_reading.rst rename to docs/api/pylabrobot.plate_reading.rst index 171927cb27..953a7d8d99 100644 --- a/docs/pylabrobot.plate_reading.rst +++ b/docs/api/pylabrobot.plate_reading.rst @@ -10,7 +10,7 @@ This package contains APIs for working with plate readers. :nosignatures: :recursive: - pylabrobot.plate_reading.plate_reader.PlateReader + plate_reader.PlateReader Backends @@ -21,4 +21,5 @@ Backends :nosignatures: :recursive: - pylabrobot.plate_reading.clario_star.CLARIOStar + chatterbox.PlateReaderChatterboxBackend + clario_star.CLARIOStar diff --git a/docs/pylabrobot.pumps.rst b/docs/api/pylabrobot.pumps.rst similarity index 77% rename from docs/pylabrobot.pumps.rst rename to docs/api/pylabrobot.pumps.rst index 09a57f6009..9dd7a28fe9 100644 --- a/docs/pylabrobot.pumps.rst +++ b/docs/api/pylabrobot.pumps.rst @@ -10,7 +10,7 @@ This package contains APIs for working with pumps. :nosignatures: :recursive: - pylabrobot.pumps.pump.Pump + pump.Pump Backends @@ -21,4 +21,5 @@ Backends :nosignatures: :recursive: - pylabrobot.pumps.cole_parmer.masterflex.Masterflex + chatterbox.PumpChatterboxBackend + cole_parmer.masterflex.Masterflex diff --git a/docs/api/pylabrobot.resources.rst b/docs/api/pylabrobot.resources.rst new file mode 100644 index 0000000000..632d3fd9cd --- /dev/null +++ b/docs/api/pylabrobot.resources.rst @@ -0,0 +1,251 @@ +.. currentmodule:: pylabrobot.resources + +pylabrobot.resources package +============================ + +Resources represent on-deck liquid handling equipment, including tip racks, plates and carriers. Many resources defined in the VENUS and Opentrons labware libraries are also defined in this package. In addition, by (optionally subclassing and) instantiating the appropriate classes, you can define your own resources. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Carrier + Container + Coordinate + Deck + ItemizedResource + utils.create_equally_spaced_2d + Lid + Liquid + PetriDish + Plate + PlateCarrier + Resource + ResourceStack + tip.Tip + TipCarrier + TipRack + Trough + Tube + TubeCarrier + TubeRack + Well + + +Azenta +------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + corning_axygen.plates + + +Boekel +------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + boekel.tube_carriers + + +Corning Axygen +-------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + corning_axygen.plates + + +Corning Costar +-------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + corning_costar.plates + + +Falcon +------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + falcon.tubes + + +Greiner +------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + greiner + greiner.plates + + +Hamilton +-------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + hamilton + hamilton.hamilton_decks.HamiltonDeck + hamilton.STARDeck + hamilton.STARLetDeck + + +Limbro +------ + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + limbro + limbro.plates + + +ML Star resources +----------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ml_star + ml_star.tip_creators + ml_star.tip_racks + ml_star.tip_carriers + ml_star.plate_carriers + + +Opentrons +--------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + opentrons + opentrons.deck + opentrons.load + opentrons.plates + opentrons.tip_racks + opentrons.tube_racks + + +Porvair +------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + porvair.plates + + +Revvity +------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + revvity.plates + + + +Tecan +----- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + tecan + tecan.plates + tecan.plate_carriers + tecan.tecan_decks + tecan.tecan_resource + tecan.tip_carriers + tecan.tip_creators + tecan.tip_racks + tecan.wash + + +Thermo Fisher +------------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + thermo_fisher.troughs + + +VWR +--- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + vwr.troughs + + +Tip trackers +------------ + +See :doc:`Using trackers ` for a tutorial. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + no_tip_tracking + set_tip_tracking + tip_tracker.TipTracker + + +Volume trackers +--------------- + +See :doc:`Using trackers ` for a tutorial. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + no_volume_tracking + set_volume_tracking + volume_tracker.VolumeTracker diff --git a/docs/pylabrobot.rst b/docs/api/pylabrobot.rst similarity index 81% rename from docs/pylabrobot.rst rename to docs/api/pylabrobot.rst index da3a6c9f43..731a74b5fd 100644 --- a/docs/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -1,7 +1,7 @@ .. currentmodule:: pylabrobot -Public API: pylabrobot package -============================== +API +=== Subpackages ----------- @@ -9,13 +9,16 @@ Subpackages .. toctree:: :maxdepth: 1 + pylabrobot.config pylabrobot.machine pylabrobot.heating_shaking pylabrobot.liquid_handling pylabrobot.plate_reading pylabrobot.pumps + pylabrobot.only_fans + pylabrobot.resources pylabrobot.scales pylabrobot.shaking - pylabrobot.resources pylabrobot.temperature_controlling + pylabrobot.tilting pylabrobot.utils diff --git a/docs/pylabrobot.scales.rst b/docs/api/pylabrobot.scales.rst similarity index 76% rename from docs/pylabrobot.scales.rst rename to docs/api/pylabrobot.scales.rst index 3bb174c6d5..63bcb13325 100644 --- a/docs/pylabrobot.scales.rst +++ b/docs/api/pylabrobot.scales.rst @@ -10,7 +10,7 @@ This package contains APIs for working with scales. :nosignatures: :recursive: - pylabrobot.scales.scale.Scale + scale.Scale Backends @@ -21,4 +21,5 @@ Backends :nosignatures: :recursive: - pylabrobot.scales.mettler_toledo.MettlerToledoWXS205SDU + chatterbox.ScaleChatterboxBackend + mettler_toledo.MettlerToledoWXS205SDU diff --git a/docs/pylabrobot.shaking.rst b/docs/api/pylabrobot.shaking.rst similarity index 79% rename from docs/pylabrobot.shaking.rst rename to docs/api/pylabrobot.shaking.rst index 7ebde74904..66f4570ad1 100644 --- a/docs/pylabrobot.shaking.rst +++ b/docs/api/pylabrobot.shaking.rst @@ -10,7 +10,7 @@ This package contains APIs for working with shakers. :nosignatures: :recursive: - pylabrobot.shaking.shaker.Shaker + shaker.Shaker Backends @@ -21,4 +21,5 @@ Backends :nosignatures: :recursive: - pylabrobot.shaking.backend.ShakerBackend + backend.ShakerBackend + chatterbox.ShakerChatterboxBackend diff --git a/docs/pylabrobot.temperature_controlling.rst b/docs/api/pylabrobot.temperature_controlling.rst similarity index 61% rename from docs/pylabrobot.temperature_controlling.rst rename to docs/api/pylabrobot.temperature_controlling.rst index 024d3777e2..eaa9e1bdf4 100644 --- a/docs/pylabrobot.temperature_controlling.rst +++ b/docs/api/pylabrobot.temperature_controlling.rst @@ -10,8 +10,8 @@ This package contains APIs for working with temperature controllers (heaters and :nosignatures: :recursive: - pylabrobot.temperature_controlling.temperature_controller.TemperatureController - pylabrobot.temperature_controlling.opentrons.OpentronsTemperatureModuleV2 + temperature_controller.TemperatureController + opentrons.OpentronsTemperatureModuleV2 Backends @@ -22,4 +22,5 @@ Backends :nosignatures: :recursive: - pylabrobot.temperature_controlling.opentrons_backend.OpentronsTemperatureModuleBackend + chatterbox.TemperatureControllerChatterboxBackend + opentrons_backend.OpentronsTemperatureModuleBackend diff --git a/docs/api/pylabrobot.tilting.rst b/docs/api/pylabrobot.tilting.rst new file mode 100644 index 0000000000..564601d276 --- /dev/null +++ b/docs/api/pylabrobot.tilting.rst @@ -0,0 +1,26 @@ +.. currentmodule:: pylabrobot.tilting + +pylabrobot.tilting package +========================== + +This package contains APIs for working with tilt modules. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + tilter.Tilter + + +Backends +-------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + chatterbox.TilterChatterboxBackend + tilter_backend.TilterBackend + hamilton_backend.HamiltonTiltModuleBackend diff --git a/docs/pylabrobot.utils.rst b/docs/api/pylabrobot.utils.rst similarity index 100% rename from docs/pylabrobot.utils.rst rename to docs/api/pylabrobot.utils.rst diff --git a/docs/conf.py b/docs/conf.py index dd6c6d0473..0623dbfc91 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -36,11 +36,12 @@ 'sphinx.ext.mathjax', 'myst_nb', 'sphinx_copybutton', - 'IPython.sphinxext.ipython_console_highlighting' + 'IPython.sphinxext.ipython_console_highlighting', + 'sphinx_reredirects', ] intersphinx_mapping = { - 'python': ('https://docs.python.org/3/', None), + 'python': ('https://docs.python.org/3/', None), } # Add any paths that contain templates here, relative to this directory. @@ -72,20 +73,50 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_book_theme' +html_theme = 'pydata_sphinx_theme' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ['_static', 'resources/library/img'] +html_extra_path = ['resources/library/img'] html_theme_options = { - 'repository_url': 'https://github.com/pylabrobot/pylabrobot', - 'use_repository_button': False, 'use_edit_page_button': True, - 'repository_branch': 'main', - 'path_to_docs': 'docs', - 'use_issues_button': False, + + "navbar_start": ["navbar-logo"], + "navbar_center": ["navbar-nav"], + "navbar_end": ["theme-switcher", "navbar-icon-links"], + "navbar_persistent": ["search-button"], + + "icon_links": [ + { + "name": "X", + "url": "https://x.com/pylabrobot", + "icon": "fa-brands fa-x-twitter", + }, + { + "name": "GitHub", + "url": "https://github.com/pylabrobot/pylabrobot", + "icon": "fa-brands fa-github", + }, + { + "name": "YouTube", + "url": "https://youtube.com/@pylabrobot", + "icon": "fa-brands fa-youtube", + } + ], + + "logo": { + "text": "PyLabRobot", + } +} + +html_context = { + "github_user": "pylabrobot", + "github_repo": "pylabrobot", + "github_version": "main", + "doc_path": "docs", } html_logo = '_static/logo.png' @@ -107,4 +138,29 @@ nb_execution_mode = 'off' myst_enable_extensions = ['dollarmath'] -source_suffix = ['.rst', '.md'] +redirects = { + "installation.html": "user_guide/installation.html", + "contributing.html": "contributor_guide/index.html", + "configuration.html": "user_guide/configuration.html", + "new-machine-type.html": "contributor_guide/new_machine_type.html", + "new-concrete-backend.html": "contributor_guide/new_concrete_backend.html", + "how-to-open-source.html": "contributor_guide/how_to_open_source.html", + "basic.html": "user_guide/basic.html", + "using-the-visualizer.html": "user_guide/using_the_visualizer.html", + "using-trackers.html": "user_guide/using_trackers.html", + "writing-robot-agnostic-methods.html": "user_guide/writing_robot_agnostic_methods.html", + "hamilton-star/hamilton-star.html": "user_guide/hamilton_star/hamilton_star.html", + "hamilton-star/iswap-module.html": "user_guide/hamilton_star/iswap_module.html", + "plate_reading.html": "user_guide/plate_reading.html", + "cytation5.html": "user_guide/cytation5.html", + "pumps.html": "user_guide/pumps.html", + "scales.html": "user_guide/scales.html", + "temperature.html": "user_guide/temperature.html", + "tilting.html": "user_guide/tilting.html", + "heating-shaking.html": "user_guide/heating_shaking.html", + "fans.html": "user_guide/fans.html", +} + +if tags.has('no-api'): + exclude_patterns.append('api/**') + suppress_warnings = ['toc.excluded'] diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index be01fee69e..0000000000 --- a/docs/contributing.md +++ /dev/null @@ -1,3 +0,0 @@ -# Contributing to PyLabRobot - -Thank you for your interest in contributing to PyLabRobot! Please see [CONTRIBUTING.md](https://github.com/PyLabRobot/pylabrobot/blob/main/CONTRIBUTING.md) on GitHub to learn how you can get started. diff --git a/docs/contributor_guide/contributing.md b/docs/contributor_guide/contributing.md new file mode 100644 index 0000000000..a214444b01 --- /dev/null +++ b/docs/contributor_guide/contributing.md @@ -0,0 +1,74 @@ +# Contributing to PyLabRobot + +Thank you for your interest in contributing to PyLabRobot! This document will help you get started. + +## Getting Started + +See the installation instructions [here](/user_guide/installation.md). For contributing, you should install PyLabRobot from source. + +If this is your first time contributing to open source, check out [How to Open Source](/contributor_guide/how-to-open-source.md) for an easy introduction. + +It's highly appreciated by the PyLabRobot developers if you communicate what you want to work on, to minimize any duplicate work. You can do this on [discuss.pylabrobot.org](https://discuss.pylabrobot.org). + +## Development Tips + +It is recommend that you use VSCode, as we provide a workspace config in `/.vscode/settings.json`, but you can use any editor you like, of course. + +Some VSCode Extensions I'd recommend: + +- [Python](https://marketplace.visualstudio.com/items?itemName=ms-python.python) +- [Pylint](https://github.com/microsoft/vscode-pylint) +- [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker) +- [mypy](https://marketplace.visualstudio.com/items?itemName=matangover.mypy) + +## Testing + +PyLabRobot uses `pytest` to run unit tests. Please make sure tests pass when you submit a PR. You can run tests as follows. + +```bash +make test # run test on the latest version +``` + +`pylint` is used to enforce code style. The rc file is `/.pylintrc`. As mentioned above, it is very helpful to have an editor do style checking as you're writing code. + +```bash +make lint +``` + +`mypy` is used to enforce type checking. + +```bash +make typecheck +``` + +## Writing documentation + +It is important that you write documentation for your code. As a rule of thumb, all functions and classes, whether public or private, are required to have a docstring. PyLabRobot uses [Google Style Python Docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html). In addition, PyLabRobot uses [type hints](https://docs.python.org/3/library/typing.html) to document the types of variables. + +To build the documentation, run `make docs` in the root directory. The documentation will be built in `docs/_build/html`. Run `open docs/_build/html/index.html` to open the documentation in your browser. + +## Common Tasks + +### Fixing a bug + +Bug fixes are an easy way to get started contributing. + +Make sure you write a test that fails before your fix and passes after your fix. This ensures that this bug will never occur again. Tests are written in `_tests.py` files. See [Python's unittest module](https://docs.python.org/3/library/unittest.html) and existing tests for more information. In most cases, adding a few additional lines to an existing test should be sufficient. + +### Adding resources + +If you have defined a new resource, it is highly appreciated by the community if you add them to the repo. In most cases, a [partial function](https://docs.python.org/3/library/functools.html#functools.partial) is enough. There are many examples, like [tipracks.py](https://github.com/PyLabRobot/pylabrobot/blob/main/pylabrobot/liquid_handling/resources/ml_star/tipracks.py). If you are writing a new kind of resource, you should probably subclass resource in a new file. + +Make sure to add your file to the imports in `__init__.py` of your resources package. + +### Writing a new backend + +Backends are the primary objects used to communicate with hardware. If you want to integrate a new piece of hardware into PyLabRobot, writing a new backend is the way to go. Here's, very generally, how you'd do it: + +1. Copy the `pylabrobot/liquid_handling/backends/backend.py` file to a new file, and rename the class to `Backend`. +2. Remove all `abc` (abstract base class) imports and decorators from the class. +3. Implement the methods in the class. + +## Support + +If you have any questions, feel free to reach out using the [PyLabRobot forum](https://discuss.pylabrobot.org). diff --git a/docs/how-to-open-source.md b/docs/contributor_guide/how-to-open-source.md similarity index 99% rename from docs/how-to-open-source.md rename to docs/contributor_guide/how-to-open-source.md index a58a620116..17da5f2c16 100644 --- a/docs/how-to-open-source.md +++ b/docs/contributor_guide/how-to-open-source.md @@ -138,4 +138,4 @@ Optionally, you can add a description of your changes. Then click "Create pull r ## Support -If you have any questions, feel free to reach out using the [PyLabRobot forum](https://forums.pylabrobot.org). +If you have any questions, feel free to reach out using the [PyLabRobot forum](https://discuss.pylabrobot.org). diff --git a/docs/img/how-to-os/branch-0.png b/docs/contributor_guide/img/how-to-os/branch-0.png similarity index 100% rename from docs/img/how-to-os/branch-0.png rename to docs/contributor_guide/img/how-to-os/branch-0.png diff --git a/docs/img/how-to-os/branch-1.png b/docs/contributor_guide/img/how-to-os/branch-1.png similarity index 100% rename from docs/img/how-to-os/branch-1.png rename to docs/contributor_guide/img/how-to-os/branch-1.png diff --git a/docs/img/how-to-os/branch-2.png b/docs/contributor_guide/img/how-to-os/branch-2.png similarity index 100% rename from docs/img/how-to-os/branch-2.png rename to docs/contributor_guide/img/how-to-os/branch-2.png diff --git a/docs/img/how-to-os/clone-0.png b/docs/contributor_guide/img/how-to-os/clone-0.png similarity index 100% rename from docs/img/how-to-os/clone-0.png rename to docs/contributor_guide/img/how-to-os/clone-0.png diff --git a/docs/img/how-to-os/clone-1.png b/docs/contributor_guide/img/how-to-os/clone-1.png similarity index 100% rename from docs/img/how-to-os/clone-1.png rename to docs/contributor_guide/img/how-to-os/clone-1.png diff --git a/docs/img/how-to-os/commit-0.png b/docs/contributor_guide/img/how-to-os/commit-0.png similarity index 100% rename from docs/img/how-to-os/commit-0.png rename to docs/contributor_guide/img/how-to-os/commit-0.png diff --git a/docs/img/how-to-os/fork-0.png b/docs/contributor_guide/img/how-to-os/fork-0.png similarity index 100% rename from docs/img/how-to-os/fork-0.png rename to docs/contributor_guide/img/how-to-os/fork-0.png diff --git a/docs/img/how-to-os/fork-1.png b/docs/contributor_guide/img/how-to-os/fork-1.png similarity index 100% rename from docs/img/how-to-os/fork-1.png rename to docs/contributor_guide/img/how-to-os/fork-1.png diff --git a/docs/img/how-to-os/pull-0.png b/docs/contributor_guide/img/how-to-os/pull-0.png similarity index 100% rename from docs/img/how-to-os/pull-0.png rename to docs/contributor_guide/img/how-to-os/pull-0.png diff --git a/docs/img/how-to-os/pull-1.png b/docs/contributor_guide/img/how-to-os/pull-1.png similarity index 100% rename from docs/img/how-to-os/pull-1.png rename to docs/contributor_guide/img/how-to-os/pull-1.png diff --git a/docs/img/how-to-os/push-0.png b/docs/contributor_guide/img/how-to-os/push-0.png similarity index 100% rename from docs/img/how-to-os/push-0.png rename to docs/contributor_guide/img/how-to-os/push-0.png diff --git a/docs/img/how-to-os/quick-0.png b/docs/contributor_guide/img/how-to-os/quick-0.png similarity index 100% rename from docs/img/how-to-os/quick-0.png rename to docs/contributor_guide/img/how-to-os/quick-0.png diff --git a/docs/img/how-to-os/quick-1.png b/docs/contributor_guide/img/how-to-os/quick-1.png similarity index 100% rename from docs/img/how-to-os/quick-1.png rename to docs/contributor_guide/img/how-to-os/quick-1.png diff --git a/docs/img/how-to-os/quick-2.png b/docs/contributor_guide/img/how-to-os/quick-2.png similarity index 100% rename from docs/img/how-to-os/quick-2.png rename to docs/contributor_guide/img/how-to-os/quick-2.png diff --git a/docs/img/how-to-os/quick-3.png b/docs/contributor_guide/img/how-to-os/quick-3.png similarity index 100% rename from docs/img/how-to-os/quick-3.png rename to docs/contributor_guide/img/how-to-os/quick-3.png diff --git a/docs/img/how-to-os/quick-4.png b/docs/contributor_guide/img/how-to-os/quick-4.png similarity index 100% rename from docs/img/how-to-os/quick-4.png rename to docs/contributor_guide/img/how-to-os/quick-4.png diff --git a/docs/img/how-to-os/quick-6.png b/docs/contributor_guide/img/how-to-os/quick-6.png similarity index 100% rename from docs/img/how-to-os/quick-6.png rename to docs/contributor_guide/img/how-to-os/quick-6.png diff --git a/docs/contributor_guide/index.md b/docs/contributor_guide/index.md new file mode 100644 index 0000000000..83fb2fdab0 --- /dev/null +++ b/docs/contributor_guide/index.md @@ -0,0 +1,15 @@ +# Contributor guide + +```{toctree} +:maxdepth: 2 + +contributing +how-to-open-source +``` + +```{toctree} +:maxdepth: 2 + +new-machine-type +new-concrete-backend +``` diff --git a/docs/new-concrete-backend.md b/docs/contributor_guide/new-concrete-backend.md similarity index 91% rename from docs/new-concrete-backend.md rename to docs/contributor_guide/new-concrete-backend.md index 87f1889e88..b9dca7d47d 100644 --- a/docs/new-concrete-backend.md +++ b/docs/contributor_guide/new-concrete-backend.md @@ -4,11 +4,11 @@ This guide explains how to add support for a new machine of an existing type. Fo The machine types that are currently supported are: -- [Liquid handlers](basic) -- [Plate readers](plate_reading) -- [Pumps](pumps) -- [Temperature controllers](temperature) -- [Heater shakers](/heating-shaking) +- [Liquid handlers](/user_guide/basic) +- [Plate readers](/user_guide/plate_reading) +- [Pumps](/user_guide/pumps) +- [Temperature controllers](/user_guide/temperature) +- [Heater shakers](/user_guide/heating-shaking) Two documents that you can read before you start are: @@ -25,7 +25,7 @@ Backends should contain minimal state. We prefer to manage the state in the fron ## 0. Get in touch -Please make a post on [the PyLabRobot Development forum](https://forums.pylabrobot.org/c/pylabrobot/23) to let us know what you are working on. This will help you avoid duplicating work, and it is also a good place to get support. +Please make a post on [the PyLabRobot Development forum](https://discuss.pylabrobot.org) to let us know what you are working on. This will help you avoid duplicating work, and it is also a good place to get support. ## 1. Creating a new concrete backend class diff --git a/docs/new-machine-type.md b/docs/contributor_guide/new-machine-type.md similarity index 92% rename from docs/new-machine-type.md rename to docs/contributor_guide/new-machine-type.md index 8a0388e85e..d0b70da4e2 100644 --- a/docs/new-machine-type.md +++ b/docs/contributor_guide/new-machine-type.md @@ -2,11 +2,11 @@ PyLabRobot supports a number of different types of machines. Currently, these are: -- [Liquid handlers](basic) -- [Plate readers](plate_reading) -- [Pumps](pumps) -- [Temperature controllers](temperature) -- [Heater shakers](/heating-shaking) +- [Liquid handlers](/user_guide/basic) +- [Plate readers](/user_guide/plate_reading) +- [Pumps](/user_guide/pumps) +- [Temperature controllers](/user_guide/temperature) +- [Heater shakers](/user_guide/heating-shaking) If you want to add support for a new type of machine, this guide will explain the process. If you want to add a new machine for a type that already exists, you should read {doc}`this guide ` instead. @@ -21,7 +21,7 @@ Thank you for contributing to PyLabRobot! ## 0. Get in touch -Please make a post on [the PyLabRobot Development forum](https://forums.pylabrobot.org/c/pylabrobot/23) to let us know what you are working on. This will help you avoid duplicating work, and it is also a good place to get support. +Please make a post on [the PyLabRobot Development forum](https://discuss.pylabrobot.org) to let us know what you are working on. This will help you avoid duplicating work, and it is also a good place to get support. ## 1. Creating a new module @@ -49,7 +49,7 @@ The abstract class {class}`~pylabrobot.machine.MachineFrontend` must be used as You should put the front end in a file called `.py` in the module you created in step 1. For example, the liquid handling front end is located at `pylabrobot.liquid_handling.liquid_handler.py`. -If your devices updates the resource tree or its state, the front end should handle this. See [the resources guide](resources/introduction.md) for more information. +If your devices updates the resource tree or its state, the front end should handle this. See [the resources guide](/resources/introduction.md) for more information. ## 4. Creating a new concrete backend for a specific machine diff --git a/docs/img/used_by/mit.jpg b/docs/img/used_by/mit.jpg new file mode 100644 index 0000000000..72c742cb70 Binary files /dev/null and b/docs/img/used_by/mit.jpg differ diff --git a/docs/img/used_by/retrobio.webp b/docs/img/used_by/retrobio.webp new file mode 100644 index 0000000000..e982979539 Binary files /dev/null and b/docs/img/used_by/retrobio.webp differ diff --git a/docs/img/used_by/tt.png b/docs/img/used_by/tt.png new file mode 100644 index 0000000000..043db25e91 Binary files /dev/null and b/docs/img/used_by/tt.png differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000..560dc1201a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,120 @@ +# Welcome to PyLabRobot's documentation! + +PyLabRobot is a hardware agnostic, pure Python SDK for liquid handling robots and accessories. + +- GitHub repository: [https://github.com/PyLabRobot/pylabrobot](https://github.com/PyLabRobot/pylabrobot) +- Community: [https://discuss.pylabrobot.org](https://discuss.pylabrobot.org) +- Paper: [https://www.cell.com/device/fulltext/S2666-9986(23)00170-9](https://www.cell.com/device/fulltext/S2666-9986(23)00170-9) + +![Graphical abstract of PyLabRobot](/img/plr.jpg) + +```{note} +PyLabRobot is different from [PyHamilton](https://github.com/dgretton/pyhamilton). While both packages are created by the same lab and both provide a Python interfaces to Hamilton robots, PyLabRobot aims to provide a universal interface to many different robots runnable on many different computers, where PyHamilton is a Windows only interface to Hamilton's VENUS. +``` + +## Used by + +```{image} /img/used_by/mit.jpg +:alt: MIT +:class: company +``` + +```{image} /img/used_by/retrobio.webp +:alt: Retro +:class: company +``` + +```{image} /img/used_by/tt.png +:alt: T-Therapeutics +:class: company tt +``` + +```{raw} html + +``` + +## Documentation + +```{toctree} +:maxdepth: 2 +:caption: User Guide + +user_guide/index +``` + +```{toctree} +:maxdepth: 2 +:caption: Development + +contributor_guide/index +``` + +```{toctree} +:maxdepth: 2 +:caption: Resource Library + +resources/index +``` + +```{toctree} +:maxdepth: 2 +:caption: API documentation + +api/pylabrobot +``` + +```{toctree} +:hidden: + +Community +``` + +## Citing + +If you use PyLabRobot in your research, please cite the following paper: + +```bibtex +@article{WIERENGA2023100111, + title = {PyLabRobot: An open-source, hardware-agnostic interface for liquid-handling robots and accessories}, + journal = {Device}, + volume = {1}, + number = {4}, + pages = {100111}, + year = {2023}, + issn = {2666-9986}, + doi = {https://doi.org/10.1016/j.device.2023.100111}, + url = {https://www.sciencedirect.com/science/article/pii/S2666998623001709}, + author = {Rick P. Wierenga and Stefan M. Golas and Wilson Ho and Connor W. Coley and Kevin M. Esvelt}, + keywords = {laboratory automation, open source, standardization, liquid-handling robots}, +} +``` + +``` +Wierenga, R., Golas, S., Ho, W., Coley, C., & Esvelt, K. (2023). PyLabRobot: An Open-Source, Hardware Agnostic Interface for Liquid-Handling Robots and Accessories. Device. https://doi.org/10.1016/j.device.2023.100111 +``` + +[Cited by](https://scholar.google.com/scholar?cites=4498189371108132583): + +- Tom, Gary, et al. "Self-driving laboratories for chemistry and materials science." Chemical Reviews (2024). +- Anhel, Ana-Mariya, Lorea Alejaldre, and Ángel Goñi-Moreno. "The Laboratory Automation Protocol (LAP) Format and Repository: a platform for enhancing workflow efficiency in synthetic biology." ACS synthetic biology 12.12 (2023): 3514-3520. +- Bultelle, Matthieu, Alexis Casas, and Richard Kitney. "Engineering biology and automation–Replicability as a design principle." Engineering Biology (2024). +- Pleiss, Jürgen. "FAIR Data and Software: Improving Efficiency and Quality of Biocatalytic Science." ACS Catalysis 14.4 (2024): 2709-2718. +- Gopal, Anjali, et al. "Will releasing the weights of large language models grant widespread access to pandemic agents?." arXiv preprint arXiv:2310.18233 (2023). +- Padhy, Shakti P., and Sergei V. Kalinin. "Domain hyper-languages bring robots together and enable the machine learning community." Device 1.4 (2023). +- Beaucage, Peter A., Duncan R. Sutherland, and Tyler B. Martin. "Automation and Machine Learning for Accelerated Polymer Characterization and Development: Past, Potential, and a Path Forward." Macromolecules (2024). +- Bultelle, Matthieu, Alexis Casas, and Richard Kitney. "Construction of a Calibration Curve for Lycopene on a Liquid-Handling Platform─ Wider Lessons for the Development of Automated Dilution Protocols." ACS Synthetic Biology (2024). +- Hysmith, Holland, et al. "The future of self-driving laboratories: from human in the loop interactive AI to gamification." Digital Discovery 3.4 (2024): 621-636. +- Casas, Alexis, Matthieu Bultelle, and Richard Kitney. "An engineering biology approach to automated workflow and biodesign." (2024). +- Jiang, Shuo, et al. "ProtoCode: Leveraging Large Language Models for Automated Generation of Machine-Readable Protocols from Scientific Publications." arXiv preprint arXiv:2312.06241 (2023). +- Jiang, Shuo, et al. "ProtoCode: Leveraging large language models (LLMs) for automated generation of machine-readable PCR protocols from scientific publications." SLAS technology 29.3 (2024): 100134. +- Thieme, Anton, et al. "Deep integration of low-cost liquid handling robots in an industrial pharmaceutical development environment." SLAS technology (2024): 100180. +- Daniel, Čech. Adaptace algoritmů pro navigaci robota na základě apriorních informací. BS thesis. České vysoké učení technické v Praze. Vypočetní a informační centrum., 2024. diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index c5efc4a693..0000000000 --- a/docs/index.rst +++ /dev/null @@ -1,102 +0,0 @@ -Welcome to PyLabRobot's documentation! -====================================== - -PyLabRobot is a hardware agnostic, pure Python library for liquid handling robots and accessories. - -PyLabRobot provides a layer of general-purpose abstractions over robot functions, with various device drivers for communicating with different kinds of robots. Right now we only support Hamilton STAR and STARLet, Tecan EVO, and Opentrons robots, but we will soon support many more. All of these robots can be controlled using any computer running any operating system. We also provide a browser-based Visualizer which can visualize the state of the deck during a run, and testing backends which do not require access to a robot. - -- GitHub repository: https://github.com/PyLabRobot/pylabrobot -- Forum: https://forums.pylabrobot.org -- Paper: https://www.cell.com/device/fulltext/S2666-9986(23)00170-9 - -.. image:: img/plr.jpg - :width: 600 - :alt: Graphical abstract of PyLabRobot - -.. note:: - PyLabRobot is different from `PyHamilton `_. While both packages are created by the same lab and both provide a Python interfaces to Hamilton robots, PyLabRobot aims to provide a universal interface to many different robots runnable on many different computers, where PyHamilton is a Windows only interface to Hamilton's VENUS. - - -.. toctree:: - :maxdepth: 1 - :caption: Getting Started - - installation.md - contributing.md - -.. toctree:: - :maxdepth: 1 - :caption: Contributing - - new-machine-type.md - new-concrete-backend.md - how-to-open-source.md - - -.. toctree:: - :maxdepth: 1 - :caption: Liquid handling - - basic - using-the-visualizer - using-trackers - writing-robot-agnostic-methods - - -.. toctree:: - :maxdepth: 1 - :caption: Resources - - resources/introduction - resources/custom-resources - resources/hamilton_parse - - -.. toctree:: - :maxdepth: 1 - :caption: Plate reading - - plate_reading - - -.. toctree:: - :maxdepth: 1 - :caption: Pumps - - pumps - - -.. toctree:: - :maxdepth: 1 - :caption: Scales - - scales - - -.. toctree:: - :maxdepth: 1 - :caption: Temperature controlling - - temperature - - -.. toctree:: - :maxdepth: 1 - :caption: Heater shakers - - heating-shaking - - -.. toctree:: - :maxdepth: 4 - :caption: API documentation - - pylabrobot - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/pylabrobot.resources.rst b/docs/pylabrobot.resources.rst deleted file mode 100644 index efa2b35ce2..0000000000 --- a/docs/pylabrobot.resources.rst +++ /dev/null @@ -1,252 +0,0 @@ -.. currentmodule:: pylabrobot - -pylabrobot.resources package -============================ - -Resources represent on-deck liquid handling equipment, including tip racks, plates and carriers. Many resources defined in the VENUS and Opentrons labware libraries are also defined in this package. In addition, by (optionally subclassing and) instantiating the appropriate classes, you can define your own resources. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources - pylabrobot.resources.Carrier - pylabrobot.resources.Container - pylabrobot.resources.Coordinate - pylabrobot.resources.Deck - pylabrobot.resources.ItemizedResource - pylabrobot.resources.create_equally_spaced - pylabrobot.resources.Lid - pylabrobot.resources.Liquid - pylabrobot.resources.PetriDish - pylabrobot.resources.Plate - pylabrobot.resources.PlateCarrier - pylabrobot.resources.Resource - pylabrobot.resources.ResourceStack - pylabrobot.resources.tip.Tip - pylabrobot.resources.TipCarrier - pylabrobot.resources.TipRack - pylabrobot.resources.Trough - pylabrobot.resources.Tube - pylabrobot.resources.TubeCarrier - pylabrobot.resources.TubeRack - pylabrobot.resources.Well - - -Azenta ------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.corning_axygen.plates - - -Boekel ------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.boekel.tube_carriers - - -Corning Axygen --------------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.corning_axygen.plates - - -Corning Costar --------------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.corning_costar.plates - - -Falcon ------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.falcon.tubes - - -Greiner -------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.greiner - pylabrobot.resources.greiner.plates - - -Hamilton --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.hamilton - pylabrobot.resources.hamilton.hamilton_decks.HamiltonDeck - pylabrobot.resources.hamilton.STARDeck - pylabrobot.resources.hamilton.STARLetDeck - - -Limbro ------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.limbro - pylabrobot.resources.limbro.plates - - -ML Star resources ------------------ - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.ml_star - pylabrobot.resources.ml_star.tip_creators - pylabrobot.resources.ml_star.tip_racks - pylabrobot.resources.ml_star.tip_carriers - pylabrobot.resources.ml_star.plate_carriers - - -Opentrons ---------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.opentrons - pylabrobot.resources.opentrons.deck - pylabrobot.resources.opentrons.load - pylabrobot.resources.opentrons.plates - pylabrobot.resources.opentrons.tip_racks - pylabrobot.resources.opentrons.tube_racks - - -Porvair -------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.porvair.plates - - -Revvity -------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.revvity.plates - - - -Tecan ------ - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.tecan - pylabrobot.resources.tecan.plates - pylabrobot.resources.tecan.plate_carriers - pylabrobot.resources.tecan.tecan_decks - pylabrobot.resources.tecan.tecan_resource - pylabrobot.resources.tecan.tip_carriers - pylabrobot.resources.tecan.tip_creators - pylabrobot.resources.tecan.tip_racks - pylabrobot.resources.tecan.wash - - -Thermo Fisher -------------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.thermo_fisher.troughs - - -VWR ---- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.vwr.troughs - - -Tip trackers ------------- - -See :doc:`Using trackers ` for a tutorial. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.no_tip_tracking - pylabrobot.resources.set_tip_tracking - pylabrobot.resources.tip_tracker.TipTracker - - -Volume trackers ---------------- - -See :doc:`Using trackers ` for a tutorial. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.resources.no_volume_tracking - pylabrobot.resources.set_volume_tracking - pylabrobot.resources.volume_tracker.VolumeTracker diff --git a/docs/resources/containers.md b/docs/resources/containers.md new file mode 100644 index 0000000000..0570348d4f --- /dev/null +++ b/docs/resources/containers.md @@ -0,0 +1,12 @@ +# Containers + +Resources that contain liquid are subclasses of {class}`pylabrobot.resources.container.Container`. This class provides a {class}`pylabrobot.resources.volume_tracker.VolumeTracker` that helps {class}`pylabrobot.liquid_handling.liquid_handler.LiquidHandler` keep track of the liquid in the resource. (For more information on trackers, check out {doc}`/user_guide/using-trackers`). Examples of subclasses of `Container` are {class}`pylabrobot.resources.Well` and {class}`pylabrobot.resources.trough.Trough`. + +It is possible to instantiate a `Container` directly: + +```python +from pylabrobot.resources import Container +container = Container(name="container", size_x=10, size_y=10, size_z=10) +# volume is computed by assuming the container is a cuboid, and can be adjusted with the max_volume +# parameter +``` diff --git a/docs/resources/custom-resources.md b/docs/resources/custom-resources.md index 6bd07409c5..32a9efdddf 100644 --- a/docs/resources/custom-resources.md +++ b/docs/resources/custom-resources.md @@ -4,7 +4,7 @@ This document describes how to define custom resources in PyLabRobot. We will bu ## Defining a custom liquid container -![Blue Bucket](../img/custom-resources/blue-bucket.jpg) +![Blue Bucket](/resources/img/custom-resources/blue-bucket.jpg) Defining create a custom liquid container, like the blue bucket above, is as easy as instantiating a {class}`pylabrobot.resources.Resource` object: @@ -62,7 +62,7 @@ The default behavior when aspirating from a resource is to aspirate from the bot lh.aspirate(blue_bucket, vols=10) ``` -![Aspirating from the blue bucket](../img/custom-resources/aspirate-blue-bucket.jpg) +![Aspirating from the blue bucket](/resources/img/custom-resources/aspirate-blue-bucket.jpg) With multiple channels, the channels will be spread evenly across the bottom of the resource: @@ -70,7 +70,7 @@ With multiple channels, the channels will be spread evenly across the bottom of await lh.aspirate(blue_bucket, vols=[10, 10, 10], use_channels=[0, 1, 2]) ``` -![Aspirating from the blue bucket with multiple channels](../img/custom-resources/aspirate-blue-bucket-multiple-channels.jpg) +![Aspirating from the blue bucket with multiple channels](/resources/img/custom-resources/aspirate-blue-bucket-multiple-channels.jpg) What happens when aspirating resources is that PLR creates a list of offsets that equally space the channels across the y-axis in the middle of the resource. These offsets are computed using {meth}`pylabrobot.resources.Resource.get_2d_center_offsets`. We can use this list and modify it to aspirate from a different location. In the example below, we will aspirate 10 mm from the left edge of the resource: @@ -80,7 +80,7 @@ offsets = [Coordinate(x=10, y=c.y, z=c.z) for c in offsets] # set x coordinate o await lh.aspirate(blue_bucket, vols=[10, 10], offsets=offsets) # pass the offsets to the aspirate ``` -![Aspirating from the blue bucket with multiple channels and custom offsets](../img/custom-resources/aspirate-blue-bucket-multiple-channels-custom-offsets.jpg) +![Aspirating from the blue bucket with multiple channels and custom offsets](/resources/img/custom-resources/aspirate-blue-bucket-multiple-channels-custom-offsets.jpg) ### Serializing data @@ -103,7 +103,7 @@ class BlueBucket(Resource): ## Defining a custom plate -![Custom Plate](../img/custom-resources/tube-plate.jpg) +![Custom Plate](/resources/img/custom-resources/tube-plate.jpg) The resource pictured above is a custom plate, consisting of tubes in a rack. @@ -125,7 +125,7 @@ class Tube(Container): Next, let's define the custom plate. The `Tube` class is passed as a type argument to the `ItemizedResource` class with `[Tube]`: ```python -from pylabrobot.resources import ItemizedResource, create_equally_spaced +from pylabrobot.resources import ItemizedResource, create_equally_spaced_2d class TubePlate(ItemizedResource[Tube]): def __init__(self, name: str): @@ -134,7 +134,7 @@ class TubePlate(ItemizedResource[Tube]): size_x=127.0, size_y=86.0, size_z=45.0, - items=create_equally_spaced(Tube, + items=create_equally_spaced_2d(Tube, num_items_x=12, num_items_y=8, dx=9.5, @@ -146,7 +146,7 @@ class TubePlate(ItemizedResource[Tube]): ) ``` -The {meth}`pylabrobot.resources.create_equally_spaced` function creates a list of items, equally spaced in a grid. +The {meth}`pylabrobot.resources.create_equally_spaced_2d` function creates a list of items, equally spaced in a grid. This resource is automatically compatible with the rest of PyLabRobot. For example, we can aspirate from the plate: @@ -158,4 +158,4 @@ lh.aspirate(tube_plate["A1":"C1"], vols=10) lh.dispense(tube_plate["A2":"C2"], vols=10) ``` -![Aspirating from the tube plate](../img/custom-resources/aspirate-tube-plate.jpg) +![Aspirating from the tube plate](/resources/img/custom-resources/aspirate-tube-plate.jpg) diff --git a/docs/resources/hamilton_parse.md b/docs/resources/hamilton_parse.md deleted file mode 100644 index 51b0f355b2..0000000000 --- a/docs/resources/hamilton_parse.md +++ /dev/null @@ -1,47 +0,0 @@ -# Parsing Hamilton VENUS Resources - -PyLabRobot allows you to easily import resources from the VENUS labware library. - -There are two ways to do this: - -1. Creating a Python resource definition file -2. Importing a resource directly into Python - -(creating-a-python-resource-definition-file)= - -## Creating a Python resource definition file - -To create a Python resource definition file, you will need to use the `make_ham_resources.py` script. This script will generate a Python resource definition file for you. - -```bash -# from the root of the repository -python tools/make_resources/make_ham_resources.py -o --filepath /path/to/file.rck -``` - -Where `` is the name of the file you want to create `path/to/file.rck` is the path to the `.rck` or `.tml` file you want to parse. - -The `-o` flag is optional. If you do not provide an output file, the script will print the resource definition to the console. - -You can also parse an entire directory of `.rck` or `.tml` files by providing the `--base-dir` flag. - -```bash -# from the root of the repository -python tools/make_resources/make_ham_resources.py -o --base-dir /path/to/directory -``` - -The Hamilton labware library is usually located at `C:\Program Files (x86)\HAMILTON\LabWare` on the Windows computer where VENUS is installed. - -## Importing a resource directly into Python - -You can also import a resource directly into Python using the `hamilton_parse` module. - -```python -from pylabrobot.resources.hamilton_parse import create_plate -create_plate('/path/to/file.rck', name='my_plate') -``` - -## Contributing resources - -Please contribute resources to PLR if you have any that are not already in the library! (This makes for a great first contribution to the project!) - -To contribute resources, create resource definitions as described [above](#creating-a-python-resource-definition-file) and add it to the `pylabrobot/resources` module. Then create a pull request. You can find a guide on creating pull requests {doc}`here `. diff --git a/docs/img/custom-resources/aspirate-blue-bucket-multiple-channels-custom-offsets.jpg b/docs/resources/img/custom-resources/aspirate-blue-bucket-multiple-channels-custom-offsets.jpg similarity index 100% rename from docs/img/custom-resources/aspirate-blue-bucket-multiple-channels-custom-offsets.jpg rename to docs/resources/img/custom-resources/aspirate-blue-bucket-multiple-channels-custom-offsets.jpg diff --git a/docs/img/custom-resources/aspirate-blue-bucket-multiple-channels.jpg b/docs/resources/img/custom-resources/aspirate-blue-bucket-multiple-channels.jpg similarity index 100% rename from docs/img/custom-resources/aspirate-blue-bucket-multiple-channels.jpg rename to docs/resources/img/custom-resources/aspirate-blue-bucket-multiple-channels.jpg diff --git a/docs/img/custom-resources/aspirate-blue-bucket.jpg b/docs/resources/img/custom-resources/aspirate-blue-bucket.jpg similarity index 100% rename from docs/img/custom-resources/aspirate-blue-bucket.jpg rename to docs/resources/img/custom-resources/aspirate-blue-bucket.jpg diff --git a/docs/img/custom-resources/aspirate-tube-plate.jpg b/docs/resources/img/custom-resources/aspirate-tube-plate.jpg similarity index 100% rename from docs/img/custom-resources/aspirate-tube-plate.jpg rename to docs/resources/img/custom-resources/aspirate-tube-plate.jpg diff --git a/docs/img/custom-resources/blue-bucket.jpg b/docs/resources/img/custom-resources/blue-bucket.jpg similarity index 100% rename from docs/img/custom-resources/blue-bucket.jpg rename to docs/resources/img/custom-resources/blue-bucket.jpg diff --git a/docs/img/custom-resources/tube-plate.jpg b/docs/resources/img/custom-resources/tube-plate.jpg similarity index 100% rename from docs/img/custom-resources/tube-plate.jpg rename to docs/resources/img/custom-resources/tube-plate.jpg diff --git a/docs/resources/img/pedestal/measure.jpeg b/docs/resources/img/pedestal/measure.jpeg new file mode 100644 index 0000000000..0f867cbecc Binary files /dev/null and b/docs/resources/img/pedestal/measure.jpeg differ diff --git a/docs/resources/img/plate/lid_nesting_z_height.jpeg b/docs/resources/img/plate/lid_nesting_z_height.jpeg new file mode 100644 index 0000000000..084b399638 Binary files /dev/null and b/docs/resources/img/plate/lid_nesting_z_height.jpeg differ diff --git a/docs/resources/index.md b/docs/resources/index.md new file mode 100644 index 0000000000..69452e681a --- /dev/null +++ b/docs/resources/index.md @@ -0,0 +1,79 @@ +# Resource Library + +The PLR Resource Library (PLR-RL) is the world's biggest and most accurate centralized collection of labware. If you cannot find something, please contribute what you are looking for! + + +```{toctree} +:maxdepth: 1 + +introduction +custom-resources +``` + +## `Resource` subclasses + +In PLR every physical object is a subclass of the `Resource` superclass (except for `Tip`). +Each subclass adds unique methods or attributes to represent its unique physical specifications and behavior. + +Some standard `Resource` subclasses in the inheritance tree are: + +``` +Resource +├── Carrier +│ ├── TipCarrier +│ ├── PlateCarrier +│ ├── MFXCarrier +│ ├── ShakerCarrier +│ └── TubeCarrier +├── Container +│ ├── Well +│ ├── PetriDish +│ ├── Tube +│ └── Trough +├── ItemizedResource +│ ├── Plate +│ ├── TipRack +│ └── TubeRack +├── Lid +├── PlateAdapter +└── MFXModule +``` + +See more detailed documentatino below (WIP). + +```{toctree} +:caption: Resource subclasses + +containers +itemized_resource +plates +plate_carriers +``` + +## Library + +### Plate Naming Standard + +PLR is not actively enforcing a specific plate naming standard but recommends the following: + +PLR_plate_naming_standards + +This standard is similar to the [Opentrons API labware naming standard](https://ecatalog.corning.com/life-sciences/b2b/UK/en/Microplates/Assay-Microplates/96-Well-Microplates/Costar%C2%AE-Multiple-Well-Cell-Culture-Plates/p/3516) but 1) further sub-categorizes "wellplates" to facilitate communication with day-to-day users, and 2) adds information about the well-bottom geometry. + +```{toctree} +:caption: Library + +library/alpaqua +library/azenta +library/boekel +library/celltreat +library/corning_axygen +library/corning_costar +library/eppendorf +library/falcon +library/ml_star +library/opentrons +library/porvair +library/revvity +library/thermo_fisher +``` diff --git a/docs/resources/introduction.md b/docs/resources/introduction.md index 690a12fb26..fe90ca13ca 100644 --- a/docs/resources/introduction.md +++ b/docs/resources/introduction.md @@ -2,17 +2,17 @@ This document introduces PyLabRobot Resources (labware and deck definitions) and general subclasses. You can find more information on creating custom resources in the {doc}`custom-resources` section. -In PyLabRobot, a {class}`pylabrobot.resources.Resource` is a piece of labware or equipment used in a protocol or program, a part of a labware item (such as a Well) or a container of labware items (such as a Deck). All resources inherit from a single base class {class}`pylabrobot.resources.Resource` that provides most of the functionality, such as the name, sizing, type, model, as well as methods for dealing with state. The name and sizing are required for all resources, with the name being a unique identifier for the resource and the sizing being the x, y and z-dimensions of the resource in millimeters when conceptualized as a cuboid. +In PyLabRobot, a {class}`pylabrobot.resources.resource.Resource` is a piece of labware or equipment used in a protocol or program, a part of a labware item (such as a Well) or a container of labware items (such as a Deck). All resources inherit from a single base class {class}`pylabrobot.resources.resource.Resource` that provides most of the functionality, such as the name, sizing, type, model, as well as methods for dealing with state. The name and sizing are required for all resources, with the name being a unique identifier for the resource and the sizing being the x, y and z-dimensions of the resource in millimeters when conceptualized as a cuboid. -While you can instantiate a `Resource` directly, several subclasses of methods exist to provide additional functionality and model specific resource attributes. For example, a {class}`pylabrobot.resources.plate.Plate` has methods for easily accessing {class}`pylabrobot.resources.Well`s. +While you can instantiate a `Resource` directly, several subclasses of methods exist to provide additional functionality and model specific resource attributes. For example, a {class}`pylabrobot.resources.plate.Plate` has methods for easily accessing {class}`pylabrobot.resources.well.Well`s. -The relation between resources is modelled by a tree, specifically an [_arborescence_]() (a directed, rooted tree). The location of a resource in the tree is a Cartesian coordinate and always relative to the bottom front left corner of its immediate parent. The absolute location can be computed using {func}`pylabrobot.resources.Resource.get_absolute_location`. The x-axis is left (smaller) and right (larger); the y-axis is front (small) and back (larger); the z-axis is down (smaller) and up (higher). Each resource has `children` and `parent` attributes that allow you to navigate the tree. +The relation between resources is modelled by a tree, specifically an [_arborescence_]() (a directed, rooted tree). The location of a resource in the tree is a Cartesian coordinate and always relative to the bottom front left corner of its immediate parent. The absolute location can be computed using {meth}`~pylabrobot.resources.resource.Resource.get_absolute_location`. The x-axis is left (smaller) and right (larger); the y-axis is front (small) and back (larger); the z-axis is down (smaller) and up (higher). Each resource has `children` and `parent` attributes that allow you to navigate the tree. -{class}`pylabrobot.machine.Machine` is a special type of resource that represents a physical machine, such as a liquid handling robot ({class}`pylabrobot.liquid_handling.liquid_handler.LiquidHandler`) or a plate reader ({class}`pylabrobot.plate_reading.plate_reader.PlateReader`). Machines have a `backend` attribute linking to the backend that is responsible for converting PyLabRobot commands into commands that a specific machine can understand. Other than that, Machines, including {class}`pylabrobot.liquid_handling.liquid_handler.LiquidHandler`, are just like any other Resource. +{class}`pylabrobot.machines.machine.Machine` is a special type of resource that represents a physical machine, such as a liquid handling robot ({class}`pylabrobot.liquid_handling.liquid_handler.LiquidHandler`) or a plate reader ({class}`pylabrobot.plate_reading.plate_reader.PlateReader`). Machines have a `backend` attribute linking to the backend that is responsible for converting PyLabRobot commands into commands that a specific machine can understand. Other than that, Machines, including {class}`pylabrobot.liquid_handling.liquid_handler.LiquidHandler`, are just like any other Resource. ## Defining a simple resource -The simplest way to define a resource is to subclass {class}`pylabrobot.resources.Resource` and define the `name` and `size_x`, `size_y` and `size_z` attributes. Here's an example of a simple resource: +The simplest way to define a resource is to subclass {class}`pylabrobot.resources.resource.Resource` and define the `name` and `size_x`, `size_y` and `size_z` attributes. Here's an example of a simple resource: ```python from pylabrobot.resources import Resource @@ -28,52 +28,6 @@ child = Resource(name="child", size_x=5, size_y=5, size_z=5) resource.assign_child_resource(child, Coordinate(x=0, y=0, z=0)) ``` -## Some common subclasses of `Resource` - -### `Container`: Resources that contain liquid - -Resources that contain liquid are subclasses of {class}`pylabrobot.resources.container.Container`. This class provides a {class}`pylabrobot.resources.volume_tracker.VolumeTracker` that helps {class}`pylabrobot.liquid_handling.liquid_handler.LiquidHandler` keep track of the liquid in the resource. (For more information on trackers, check out {doc}`/using-trackers`). Examples of subclasses of `Container` are {class}`pylabrobot.resources.Well` and {class}`pylabrobot.resources.trough.Trough`. - -It is possible to instantiate a `Container` directly: - -```python -from pylabrobot.resources import Container -container = Container(name="container", size_x=10, size_y=10, size_z=10) -# volume is computed by assuming the container is a cuboid, and can be adjusted with the max_volume -# parameter -``` - -### `ItemizedResource`: Resources that contain items in a grid - -Resources that contain items in a grid are subclasses of {class}`pylabrobot.resources.itemized_resource.ItemizedResource`. This class provides convenient methods for accessing the child-resources, such as by integer or SBS "A1" style-notation, as well as for traversing items in an `ItemizedResource`. Examples of subclasses of `ItemizedResource`s are {class}`pylabrobot.resources.plate.Plate` and {class}`pylabrobot.resources.tip_rack.TipRack`. - -To instantiate an `ItemizedResource`, it is convenient to use the `pylabrobot.resources.itemized_resource.create_equally_spaced` method to quickly initialize a grid of child-resources in a grid. Here's an example of a simple `ItemizedResource`: - -```python -from pylabrobot.resources import ItemizedResource -from pylabrobot.resources.itemized_resource import create_equally_spaced -from pylabrobot.resources.well import Well, WellBottomType - -plate = ItemizedResource( - name="plate", - size_x=127, - size_y=86, - size_z=10, - items=create_equally_spaced( - Well, # the class of the items - num_items_x=12, - num_items_y=8, - dx=12, # distance between the first well and the border in the x-axis - dy=12, # distance between the first well and the border in the y-axis - dz=0, # distance between the first well and the border in the z-axis - item_dx=9, # distance between the wells in the x-axis - item_dy=9, # distance between the wells in the y-axis - - bottom_type=WellBottomType.FLAT, # a custom keyword argument passed to the Well initializer - ) -) -``` - ## Saving and loading resources PyLabRobot provide utilities to save and load resources and their states to and from files, as well as to serialize and deserialize resources and their states to and from Python dictionaries. @@ -82,7 +36,7 @@ PyLabRobot provide utilities to save and load resources and their states to and #### Saving to and loading from a file -Resource definitions, that includes deck definitions, can be saved to and loaded from a file using the `pylabrobot.resources.Resource.save` and `pylabrobot.resources.Resource.load` methods. The file format is JSON. +Resource definitions, that includes deck definitions, can be saved to and loaded from a file using the `pylabrobot.resources.resource.Resource.save` and `pylabrobot.resources.resource.Resource.load` methods. The file format is JSON. To save a resource to a file: @@ -133,7 +87,7 @@ Each Resource is responsible for managing its own state, as deep down in the arb #### Serializing and deserializing state -The state of a single resource, that includes the volume of a container, can be serialized to and deserialized from a Python dictionary using the `pylabrobot.resources.Resource.serialize_state` and `pylabrobot.resources.Resource.deserialize_state` methods. +The state of a single resource, that includes the volume of a container, can be serialized to and deserialized from a Python dictionary using the `pylabrobot.resources.resource.Resource.serialize_state` and `pylabrobot.resources.resource.Resource.deserialize_state` methods. To serialize the state of a resource: @@ -158,11 +112,11 @@ c.load_state({ "liquids": [], "pending_liquids": [] }) This is convenient if you want to use PLR state in your own state management system, or save to a database. -Note that above, only the state of a single resource is serialized. If you want to serialize the state of a resource and all its children, you can use the {func}`pylabrobot.resources.Resource.serialize_all_state` and {func}`pylabrobot.resources.Resource.load_all_state` methods. These methods are used internally by the `save_state_to_file` and `load_state_from_file` methods. +Note that above, only the state of a single resource is serialized. If you want to serialize the state of a resource and all its children, you can use the {meth}`pylabrobot.resources.resource.Resource.serialize_all_state` and {meth}`pylabrobot.resources.resource.Resource.load_all_state` methods. These methods are used internally by the `save_state_to_file` and `load_state_from_file` methods. #### Saving and loading state to and from a file -The state of a resource, that includes the volume of a container, can be saved to and loaded from a file using the `pylabrobot.resources.Resource.save_state_to_file` and `pylabrobot.resources.Resource.load_state_from_file` methods. The file format is JSON. +The state of a resource, that includes the volume of a container, can be saved to and loaded from a file using the `pylabrobot.resources.resource.Resource.save_state_to_file` and `pylabrobot.resources.resource.Resource.load_state_from_file` methods. The file format is JSON. To save the state of a resource to a file: diff --git a/docs/resources/itemized_resource.md b/docs/resources/itemized_resource.md new file mode 100644 index 0000000000..ae70cb3e10 --- /dev/null +++ b/docs/resources/itemized_resource.md @@ -0,0 +1,30 @@ +# ItemizedResource + +Resources that contain items in a grid are subclasses of {class}`pylabrobot.resources.itemized_resource.ItemizedResource`. This class provides convenient methods for accessing the child-resources, such as by integer or SBS "A1" style-notation, as well as for traversing items in an `ItemizedResource`. Examples of subclasses of `ItemizedResource`s are {class}`pylabrobot.resources.plate.Plate` and {class}`pylabrobot.resources.tip_rack.TipRack`. + +To instantiate an `ItemizedResource`, it is convenient to use the `pylabrobot.resources.utils.create_equally_spaced_2d` method to quickly initialize a grid of child-resources in a grid. Here's an example of a simple `ItemizedResource`: + +```python +from pylabrobot.resources import ItemizedResource +from pylabrobot.resources.utils import create_equally_spaced_2d +from pylabrobot.resources.well import Well, WellBottomType + +plate = ItemizedResource( + name="plate", + size_x=127, + size_y=86, + size_z=10, + items=create_equally_spaced_2d( + Well, # the class of the items + num_items_x=12, + num_items_y=8, + dx=12, # distance between the first well and the border in the x-axis + dy=12, # distance between the first well and the border in the y-axis + dz=0, # distance between the first well and the border in the z-axis + item_dx=9, # distance between the wells in the x-axis + item_dy=9, # distance between the wells in the y-axis + + bottom_type=WellBottomType.FLAT, # a custom keyword argument passed to the Well initializer + ) +) +``` diff --git a/docs/resources/library/alpaqua.md b/docs/resources/library/alpaqua.md new file mode 100644 index 0000000000..7ca7f097f9 --- /dev/null +++ b/docs/resources/library/alpaqua.md @@ -0,0 +1,12 @@ +# Alpaqua Engineering, LLC + +Company page: [Alpaqua Engineering, LLC](https://www.alpaqua.com/about-us/) + +> Alpaqua Engineering, LLC, founded in 2006, is a global provider of tools for accelerating genomic applications such as NGS, nucleic acid extraction and clean up, target capture, and molecular diagnostics. +Our products include a line of innovative, high performance magnet plates built with proprietary magnet architecture and spring cushion technology. Also available are aluminum tube blocks to help maintain temperature control, SBS /ANSI standard tube racks and the Alpillo® Plate Cushion, which enables pipetting from the bottom of a well without tip occlusion​. + +## Labware + +| Description | Image | PLR definition | +|-|-|-| +| 'Alpaqua_96_magnum_flx'
Part no.: A000400
[manufacturer website](https://www.alpaqua.com/product/magnum-flx/) | ![](img/alpaqua/Alpaqua_96_magnum_flx.jpg) | `Alpaqua_96_magnum_flx` | diff --git a/pylabrobot/resources/azenta/README.md b/docs/resources/library/azenta.md similarity index 80% rename from pylabrobot/resources/azenta/README.md rename to docs/resources/library/azenta.md index cf0cf30377..34ed3ea1a2 100644 --- a/pylabrobot/resources/azenta/README.md +++ b/docs/resources/library/azenta.md @@ -1,5 +1,4 @@ - -## Resource defintions: Azenta +# Azenta Company wikipedia: [Azenta](https://en.wikipedia.org/wiki/Azenta) @@ -7,8 +6,8 @@ Company wikipedia: [Azenta](https://en.wikipedia.org/wiki/Azenta) > In 2017, Brooks acquired 4titude, a maker of scientific tools and consumables, while in 2018, Brooks acquired GENEWIZ, a genomics services provider as part of their life sciences division's expansion. > In November 2021, Brooks Automation Inc. split into two entities, Brooks Automation and Azenta Life Sciences. The latter will focus exclusively on their life science division. -### Currently defined labware: +## Plates | Description | Image | PLR definition | |--------------------|--------------------|--------------------| -| 'Azenta4titudeFrameStar_96_wellplate_skirted'

- Man. part no.: 4ti-0960
- Supplier part no.: PCR1232
- [manufacturer website](https://www.azenta.com/products/framestar-96-well-skirted-pcr-plate)
- [supplier website](https://www.scientificlabs.co.uk/product/pcr-plates/PCR1232)
- working volume: <100µl
- total well capacity: 200µl| Azenta4titudeFrameStar_96_wellplate_skirted | `Azenta4titudeFrameStar_96_wellplate_skirted` | +| 'Azenta4titudeFrameStar_96_wellplate_skirted'

- Man. part no.: 4ti-0960
- Supplier part no.: PCR1232
- [manufacturer website](https://www.azenta.com/products/framestar-96-well-skirted-pcr-plate)
- [supplier website](https://www.scientificlabs.co.uk/product/pcr-plates/PCR1232)
- working volume: <100µl
- total well capacity: 200µl| ![](img/azenta/azenta_4titude_96PCR_4ti-0960.jpg) | `Azenta4titudeFrameStar_96_wellplate_skirted` | diff --git a/pylabrobot/resources/boekel/README.md b/docs/resources/library/boekel.md similarity index 63% rename from pylabrobot/resources/boekel/README.md rename to docs/resources/library/boekel.md index cacf2b7abc..5e23166b93 100644 --- a/pylabrobot/resources/boekel/README.md +++ b/docs/resources/library/boekel.md @@ -1,4 +1,4 @@ -# Resource definitions: Boekel +# Boekel ## Tube carrier @@ -11,7 +11,7 @@ The following rack exists in 4 orientations: | Description | Image | PLR definition | |--------------------|--------------------|--------------------| -| Multi Tube Rack For 50ml Conical, 15ml Conical, And Microcentrifuge Tubes, PN:120008 [manufacturer website](https://www.boekelsci.com/multi-tube-rack-for-50ml-conical-15ml-conical-and-microcentrifuge-tubes-pn-120008.html) | ThermoFisherMatrixTrough8094.jpg.avif | `boekel_50mL_falcon_carrier` | -| Multi Tube Rack For 50ml Conical, 15ml Conical, And Microcentrifuge Tubes, PN:120008 [manufacturer website](https://www.boekelsci.com/multi-tube-rack-for-50ml-conical-15ml-conical-and-microcentrifuge-tubes-pn-120008.html) | ThermoFisherMatrixTrough8094.jpg.avif | `boekel_15mL_falcon_carrier` | -| Multi Tube Rack For 50ml Conical, 15ml Conical, And Microcentrifuge Tubes, PN:120008 [manufacturer website](https://www.boekelsci.com/multi-tube-rack-for-50ml-conical-15ml-conical-and-microcentrifuge-tubes-pn-120008.html) | ThermoFisherMatrixTrough8094.jpg.avif | `boekel_1_5mL_microcentrifuge_carrier` | -| Multi Tube Rack For 50ml Conical, 15ml Conical, And Microcentrifuge Tubes, PN:120008 [manufacturer website](https://www.boekelsci.com/multi-tube-rack-for-50ml-conical-15ml-conical-and-microcentrifuge-tubes-pn-120008.html) | ThermoFisherMatrixTrough8094.jpg.avif | `boekel_mini_microcentrifuge_carrier` | +| Multi Tube Rack For 50ml Conical, 15ml Conical, And Microcentrifuge Tubes, PN:120008 [manufacturer website](https://www.boekelsci.com/multi-tube-rack-for-50ml-conical-15ml-conical-and-microcentrifuge-tubes-pn-120008.html) | ![](img/boekel/boekel_carrier50mL.jpg) | `boekel_50mL_falcon_carrier` | +| Multi Tube Rack For 50ml Conical, 15ml Conical, And Microcentrifuge Tubes, PN:120008 [manufacturer website](https://www.boekelsci.com/multi-tube-rack-for-50ml-conical-15ml-conical-and-microcentrifuge-tubes-pn-120008.html) | ![](img/boekel/boekel_carrier15mL.jpg) | `boekel_15mL_falcon_carrier` | +| Multi Tube Rack For 50ml Conical, 15ml Conical, And Microcentrifuge Tubes, PN:120008 [manufacturer website](https://www.boekelsci.com/multi-tube-rack-for-50ml-conical-15ml-conical-and-microcentrifuge-tubes-pn-120008.html) | ![](img/boekel/boekel_carrier1_5mL.jpg) | `boekel_1_5mL_microcentrifuge_carrier` | +| Multi Tube Rack For 50ml Conical, 15ml Conical, And Microcentrifuge Tubes, PN:120008 [manufacturer website](https://www.boekelsci.com/multi-tube-rack-for-50ml-conical-15ml-conical-and-microcentrifuge-tubes-pn-120008.html) | ![](img/boekel/boekel_carrier_mini.jpg) | `boekel_mini_microcentrifuge_carrier` | diff --git a/docs/resources/library/celltreat.md b/docs/resources/library/celltreat.md new file mode 100644 index 0000000000..23a437fc93 --- /dev/null +++ b/docs/resources/library/celltreat.md @@ -0,0 +1,8 @@ +# CellTreat + +## Plats + +| Description | Image | PLR definition | +|-|-|-| +| 'CellTreat_6_DWP_16300ul_Fb'
Part no.: 229105
[manufacturer website](https://www.celltreat.com/product/229105/) | ![](img/celltreat/CellTreat_6_DWP_16300ul_Fb.jpg) | `CellTreat_6_DWP_16300ul_Fb` | +| 'CellTreat_96_DWP_350ul_Ub'
Part no.: 229591
[manufacturer website](https://www.celltreat.com/product/229591/) | ![](img/celltreat/CellTreat_96_DWP_350ul_Ub.jpg) | `CellTreat_96_DWP_350ul_Ub` | diff --git a/pylabrobot/resources/corning_axygen/README.md b/docs/resources/library/corning_axygen.md similarity index 72% rename from pylabrobot/resources/corning_axygen/README.md rename to docs/resources/library/corning_axygen.md index 0dad75974a..8e5438ee2e 100644 --- a/pylabrobot/resources/corning_axygen/README.md +++ b/docs/resources/library/corning_axygen.md @@ -1,12 +1,11 @@ - -## Resource defintions: Corning - Axygen +# Corning - Axygen Company page: [Corning - Axygen® Brand Products](https://www.corning.com/emea/en/products/life-sciences/resources/brands/axygen-brand-products.html) > Corning acquired Axygen BioScience, Inc. and its subsidiaries in 2009. This acquisition included Axygen's broad portfolio of high-quality plastic consumables, liquid handling products, and bench-top laboratory equipment, which complemented and expanded Corning's offerings in the life sciences segment​. -### Currently defined labware: +## Plates -| Description | Image | -|--------------------|--------------------| -| 'Axy_24_DW_10ML'
Part no.: P-DW-10ML-24-C-S
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/UK/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-DW-10ML-24-C-S) | Axy_24_DW_10ML | `Axy_24_DW_10ML` | +| Description | Image | PLR definition | +|-|-|-| +| 'Axy_24_DW_10ML'
Part no.: P-DW-10ML-24-C-S
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/UK/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-DW-10ML-24-C-S) | ![](img/corning_axygen/axygen_Axy_24_DW_10ML.jpg) | `Axy_24_DW_10ML` | diff --git a/docs/resources/library/corning_costar.md b/docs/resources/library/corning_costar.md new file mode 100644 index 0000000000..4a55ab1bb2 --- /dev/null +++ b/docs/resources/library/corning_costar.md @@ -0,0 +1,15 @@ +# Corning - Costar + +Wikipedia page: [Corning](https://en.wikipedia.org/wiki/Corning_Inc.) + +> CCorning Incorporated is an American multinational technology company that specializes in specialty glass, ceramics, and related materials and technologies including advanced optics, primarily for industrial and scientific applications. The company was named Corning Glass Works until 1989. Corning divested its consumer product lines (including CorningWare and Visions Pyroceram-based cookware, Corelle Vitrelle tableware, and Pyrex glass bakeware) in 1998 by selling the Corning Consumer Products Company subsidiary (later Corelle Brands, now known as Instant Brands) to Borden. + +As of 2014, Corning had five major business sectors: display technologies, environmental technologies, life sciences, optical communications, and specialty materials. + +## Plates + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'Cos_6_MWP_16800ul_Fb'
Part no.: 3516
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/UK/en/Microplates/Assay-Microplates/96-Well-Microplates/Costar%C2%AE-Multiple-Well-Cell-Culture-Plates/p/3516)

- Material: ?
- Cleanliness: 3516: sterilized by gamma irradiation
- Nonreversible lids with condensation rings to reduce contamination
- Treated for optimal cell attachment
- Cell growth area: 9.5 cm² (approx.)
- Total volume: 16.8 mL| ![](img/corning_costar/Cos_6_MWP_16800ul_Fb.jpg) | `Cos_6_MWP_16800ul_Fb` | +| 'Cos_96_DWP_2mL_Vb'
Part no.: 3516
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/UK/en/Microplates/Assay-Microplates/96-Well-Microplates/Costar%C2%AE-Multiple-Well-Cell-Culture-Plates/p/3516)

- Material: Polypropylene
- Resistant to many common organic solvents (e.g., DMSO, ethanol, methanol)
- 3960: Sterile and DNase- and RNase-free
- Total volume: 2 mL
- Features uniform skirt heights for greater robotic gripping surface| ![](img/corning_costar/Cos_96_DWP_2mL_Vb.jpg) | `Cos_96_DWP_2mL_Vb` | +[ 'Cor_96_wellplate_360ul_Fb'
Part no.: 353376
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/NL/en/Microplates/Assay-Microplates/96-Well-Microplates/Falcon®-96-well-Polystyrene-Microplates/p/353376)

- Material: TC-treated polystyrene
- Cleanliness: sterile
- Total volume: 392 uL
- Working volume: 25-340 uL | ![](img/corning_costar/Cos_96_wellplate_360ul_Fb.jpg) | `Cor_96_wellplate_360ul_Fb` | diff --git a/docs/resources/library/eppendorf.md b/docs/resources/library/eppendorf.md new file mode 100644 index 0000000000..0c70ad2f81 --- /dev/null +++ b/docs/resources/library/eppendorf.md @@ -0,0 +1,15 @@ +# Eppendorf + +Company page: [Eppendorf Wikipedia](https://en.wikipedia.org/wiki/Eppendorf_(company)) + +> Eppendorf, a company with its registered office in Germany, develops, produces and sells products and services for laboratories around the world. + +> Founding year: 1945 +> Company type: private + + +## Plates + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'Eppendorf_96_wellplate_250ul_Vb'
Part no.: 0030133374
[manufacturer website](https://www.eppendorf.com/gb-en/Products/Laboratory-Consumables/Plates/Eppendorf-twintec-PCR-Plates-p-0030133374)

- Material: polycarbonate (frame), polypropylene (wells)
- part of the twin.tec(R) product line
- WARNING: not ANSI/SLAS 1-2004 footprint dimenions (123x81 mm^2!) ==> requires `PlateAdapter`
- 'Can be divided into 4 segments of 24 wells each to prevent waste and save money'. | ![](img/eppendorf/Eppendorf_96_wellplate_250ul_Vb_COMPLETE.png) ![](img/eppendorf/Eppendorf_96_wellplate_250ul_Vb_DIVIDED.png) | `Eppendorf_96_wellplate_250ul_Vb` | diff --git a/docs/resources/library/falcon.md b/docs/resources/library/falcon.md new file mode 100644 index 0000000000..f6432be107 --- /dev/null +++ b/docs/resources/library/falcon.md @@ -0,0 +1,9 @@ +# Falcon + +## Tubes + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 50mL Falcon Tube [manufacturer website](https://www.fishersci.com/shop/products/falcon-50ml-conical-centrifuge-tubes-2/1495949A) | ![](img/falcon/falcon-tube-50mL.webp) | `falcon_tube_50mL` +| 15mL Falcon Tube [manufacturer website](https://www.fishersci.com/shop/products/falcon-15ml-conical-centrifuge-tubes-5/p-193301) | ![](img/falcon/falcon-tube-15mL.webp) | `falcon_tube_15mL` +| Falcon_tube_14mL_Rb
Corning cat. no.: 352059
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/UK/en/General-Labware/Tubes/Tubes,-Round-Bottom/Falcon%C2%AE-Round-Bottom-High-clarity-Polypropylene-Tube/p/352059) | ![](img/falcon/Falcon_tube_14mL_Rb.jpg) | `Falcon_tube_14mL_Rb` diff --git a/docs/resources/library/img/alpaqua/Alpaqua_96_magnum_flx.jpg b/docs/resources/library/img/alpaqua/Alpaqua_96_magnum_flx.jpg new file mode 100644 index 0000000000..d774f36e2a Binary files /dev/null and b/docs/resources/library/img/alpaqua/Alpaqua_96_magnum_flx.jpg differ diff --git a/pylabrobot/resources/azenta/ims/azenta_4titude_96PCR_4ti-0960.jpg b/docs/resources/library/img/azenta/azenta_4titude_96PCR_4ti-0960.jpg similarity index 100% rename from pylabrobot/resources/azenta/ims/azenta_4titude_96PCR_4ti-0960.jpg rename to docs/resources/library/img/azenta/azenta_4titude_96PCR_4ti-0960.jpg diff --git a/pylabrobot/resources/boekel/imgs/boekel_carrier15mL.jpg b/docs/resources/library/img/boekel/boekel_carrier15mL.jpg similarity index 100% rename from pylabrobot/resources/boekel/imgs/boekel_carrier15mL.jpg rename to docs/resources/library/img/boekel/boekel_carrier15mL.jpg diff --git a/pylabrobot/resources/boekel/imgs/boekel_carrier1_5mL.jpg b/docs/resources/library/img/boekel/boekel_carrier1_5mL.jpg similarity index 100% rename from pylabrobot/resources/boekel/imgs/boekel_carrier1_5mL.jpg rename to docs/resources/library/img/boekel/boekel_carrier1_5mL.jpg diff --git a/pylabrobot/resources/boekel/imgs/boekel_carrier50mL.jpg b/docs/resources/library/img/boekel/boekel_carrier50mL.jpg similarity index 100% rename from pylabrobot/resources/boekel/imgs/boekel_carrier50mL.jpg rename to docs/resources/library/img/boekel/boekel_carrier50mL.jpg diff --git a/pylabrobot/resources/boekel/imgs/boekel_carrier_mini.jpg b/docs/resources/library/img/boekel/boekel_carrier_mini.jpg similarity index 100% rename from pylabrobot/resources/boekel/imgs/boekel_carrier_mini.jpg rename to docs/resources/library/img/boekel/boekel_carrier_mini.jpg diff --git a/docs/resources/library/img/celltreat/CellTreat_6_DWP_16300ul_Fb.jpg b/docs/resources/library/img/celltreat/CellTreat_6_DWP_16300ul_Fb.jpg new file mode 100644 index 0000000000..8db01e4f44 Binary files /dev/null and b/docs/resources/library/img/celltreat/CellTreat_6_DWP_16300ul_Fb.jpg differ diff --git a/docs/resources/library/img/celltreat/CellTreat_96_DWP_350ul_Ub.jpg b/docs/resources/library/img/celltreat/CellTreat_96_DWP_350ul_Ub.jpg new file mode 100644 index 0000000000..3c1193b04a Binary files /dev/null and b/docs/resources/library/img/celltreat/CellTreat_96_DWP_350ul_Ub.jpg differ diff --git a/pylabrobot/resources/corning_axygen/ims/axygen_Axy_24_DW_10ML.jpg b/docs/resources/library/img/corning_axygen/axygen_Axy_24_DW_10ML.jpg similarity index 100% rename from pylabrobot/resources/corning_axygen/ims/axygen_Axy_24_DW_10ML.jpg rename to docs/resources/library/img/corning_axygen/axygen_Axy_24_DW_10ML.jpg diff --git a/docs/resources/library/img/corning_costar/Cos_6_MWP_16800ul_Fb.jpg b/docs/resources/library/img/corning_costar/Cos_6_MWP_16800ul_Fb.jpg new file mode 100644 index 0000000000..5ff1c3909c Binary files /dev/null and b/docs/resources/library/img/corning_costar/Cos_6_MWP_16800ul_Fb.jpg differ diff --git a/docs/resources/library/img/corning_costar/Cos_96_DWP_2mL_Vb.jpg b/docs/resources/library/img/corning_costar/Cos_96_DWP_2mL_Vb.jpg new file mode 100644 index 0000000000..d357b3148d Binary files /dev/null and b/docs/resources/library/img/corning_costar/Cos_96_DWP_2mL_Vb.jpg differ diff --git a/docs/resources/library/img/corning_costar/Cos_96_wellplate_360ul_Fb.jpg b/docs/resources/library/img/corning_costar/Cos_96_wellplate_360ul_Fb.jpg new file mode 100644 index 0000000000..47445653fb Binary files /dev/null and b/docs/resources/library/img/corning_costar/Cos_96_wellplate_360ul_Fb.jpg differ diff --git a/docs/resources/library/img/eppendorf/Eppendorf_96_wellplate_250ul_Vb_COMPLETE.png b/docs/resources/library/img/eppendorf/Eppendorf_96_wellplate_250ul_Vb_COMPLETE.png new file mode 100644 index 0000000000..ee9a0b4b7b Binary files /dev/null and b/docs/resources/library/img/eppendorf/Eppendorf_96_wellplate_250ul_Vb_COMPLETE.png differ diff --git a/docs/resources/library/img/eppendorf/Eppendorf_96_wellplate_250ul_Vb_DIVIDED.png b/docs/resources/library/img/eppendorf/Eppendorf_96_wellplate_250ul_Vb_DIVIDED.png new file mode 100644 index 0000000000..b2ad38823c Binary files /dev/null and b/docs/resources/library/img/eppendorf/Eppendorf_96_wellplate_250ul_Vb_DIVIDED.png differ diff --git a/pylabrobot/resources/falcon/imgs/Falcon_tube_14mL_Rb.jpg b/docs/resources/library/img/falcon/Falcon_tube_14mL_Rb.jpg similarity index 100% rename from pylabrobot/resources/falcon/imgs/Falcon_tube_14mL_Rb.jpg rename to docs/resources/library/img/falcon/Falcon_tube_14mL_Rb.jpg diff --git a/pylabrobot/resources/falcon/imgs/falcon-tube-15mL.webp b/docs/resources/library/img/falcon/falcon-tube-15mL.webp similarity index 100% rename from pylabrobot/resources/falcon/imgs/falcon-tube-15mL.webp rename to docs/resources/library/img/falcon/falcon-tube-15mL.webp diff --git a/pylabrobot/resources/falcon/imgs/falcon-tube-50mL.webp b/docs/resources/library/img/falcon/falcon-tube-50mL.webp similarity index 100% rename from pylabrobot/resources/falcon/imgs/falcon-tube-50mL.webp rename to docs/resources/library/img/falcon/falcon-tube-50mL.webp diff --git a/pylabrobot/resources/ml_star/ims/Hamilton_1_trough_200ml_Vb.jpg b/docs/resources/library/img/ml_star/Hamilton_1_trough_200ml_Vb.jpg similarity index 100% rename from pylabrobot/resources/ml_star/ims/Hamilton_1_trough_200ml_Vb.jpg rename to docs/resources/library/img/ml_star/Hamilton_1_trough_200ml_Vb.jpg diff --git a/pylabrobot/resources/ml_star/ims/Hamilton_96_adapter_188182.png b/docs/resources/library/img/ml_star/Hamilton_96_adapter_188182.png similarity index 100% rename from pylabrobot/resources/ml_star/ims/Hamilton_96_adapter_188182.png rename to docs/resources/library/img/ml_star/Hamilton_96_adapter_188182.png diff --git a/docs/resources/library/img/ml_star/Hamilton_96_tiprack_50ul_NTR.png b/docs/resources/library/img/ml_star/Hamilton_96_tiprack_50ul_NTR.png new file mode 100644 index 0000000000..85869b4ef0 Binary files /dev/null and b/docs/resources/library/img/ml_star/Hamilton_96_tiprack_50ul_NTR.png differ diff --git a/docs/resources/library/img/ml_star/Hamilton_96_tiprack_50ul_NTR_CLEAR.png b/docs/resources/library/img/ml_star/Hamilton_96_tiprack_50ul_NTR_CLEAR.png new file mode 100644 index 0000000000..17420acfda Binary files /dev/null and b/docs/resources/library/img/ml_star/Hamilton_96_tiprack_50ul_NTR_CLEAR.png differ diff --git a/pylabrobot/resources/ml_star/ims/Hamilton_carrier_naming_guide.png b/docs/resources/library/img/ml_star/Hamilton_carrier_naming_guide.png similarity index 100% rename from pylabrobot/resources/ml_star/ims/Hamilton_carrier_naming_guide.png rename to docs/resources/library/img/ml_star/Hamilton_carrier_naming_guide.png diff --git a/pylabrobot/resources/ml_star/ims/MFX_CAR_L4_SHAKER_187001.png b/docs/resources/library/img/ml_star/MFX_CAR_L4_SHAKER_187001.png similarity index 100% rename from pylabrobot/resources/ml_star/ims/MFX_CAR_L4_SHAKER_187001.png rename to docs/resources/library/img/ml_star/MFX_CAR_L4_SHAKER_187001.png diff --git a/pylabrobot/resources/ml_star/ims/MFX_CAR_L5_base_188039.jpg b/docs/resources/library/img/ml_star/MFX_CAR_L5_base_188039.jpg similarity index 100% rename from pylabrobot/resources/ml_star/ims/MFX_CAR_L5_base_188039.jpg rename to docs/resources/library/img/ml_star/MFX_CAR_L5_base_188039.jpg diff --git a/pylabrobot/resources/ml_star/ims/MFX_DWP_RB_module_188229_.jpg b/docs/resources/library/img/ml_star/MFX_DWP_RB_module_188229_.jpg similarity index 100% rename from pylabrobot/resources/ml_star/ims/MFX_DWP_RB_module_188229_.jpg rename to docs/resources/library/img/ml_star/MFX_DWP_RB_module_188229_.jpg diff --git a/pylabrobot/resources/ml_star/ims/MFX_TIP_module_188040.jpg b/docs/resources/library/img/ml_star/MFX_TIP_module_188040.jpg similarity index 100% rename from pylabrobot/resources/ml_star/ims/MFX_TIP_module_188040.jpg rename to docs/resources/library/img/ml_star/MFX_TIP_module_188040.jpg diff --git a/pylabrobot/resources/ml_star/ims/PLT_CAR_L5AC_A00_182090.jpg b/docs/resources/library/img/ml_star/PLT_CAR_L5AC_A00_182090.jpg similarity index 100% rename from pylabrobot/resources/ml_star/ims/PLT_CAR_L5AC_A00_182090.jpg rename to docs/resources/library/img/ml_star/PLT_CAR_L5AC_A00_182090.jpg diff --git a/docs/resources/library/img/ml_star/TIP_50ul_L.png b/docs/resources/library/img/ml_star/TIP_50ul_L.png new file mode 100644 index 0000000000..d10e4b91aa Binary files /dev/null and b/docs/resources/library/img/ml_star/TIP_50ul_L.png differ diff --git a/pylabrobot/resources/ml_star/ims/TIP_CAR_480_A00_182085.jpg b/docs/resources/library/img/ml_star/TIP_CAR_480_A00_182085.jpg similarity index 100% rename from pylabrobot/resources/ml_star/ims/TIP_CAR_480_A00_182085.jpg rename to docs/resources/library/img/ml_star/TIP_CAR_480_A00_182085.jpg diff --git a/pylabrobot/resources/ml_star/ims/Trough_CAR_4R200_A00.png b/docs/resources/library/img/ml_star/Trough_CAR_4R200_A00.png similarity index 100% rename from pylabrobot/resources/ml_star/ims/Trough_CAR_4R200_A00.png rename to docs/resources/library/img/ml_star/Trough_CAR_4R200_A00.png diff --git a/pylabrobot/resources/ml_star/ims/Tube_CAR_24_A00.png b/docs/resources/library/img/ml_star/Tube_CAR_24_A00.png similarity index 100% rename from pylabrobot/resources/ml_star/ims/Tube_CAR_24_A00.png rename to docs/resources/library/img/ml_star/Tube_CAR_24_A00.png diff --git a/docs/resources/library/img/opentrons/Opentrons_96_adapter_Vb.jpg b/docs/resources/library/img/opentrons/Opentrons_96_adapter_Vb.jpg new file mode 100644 index 0000000000..d68210e97b Binary files /dev/null and b/docs/resources/library/img/opentrons/Opentrons_96_adapter_Vb.jpg differ diff --git a/pylabrobot/resources/porvair/ims/porvair_6x47_reservoir_390015.jpg b/docs/resources/library/img/porvair/porvair_6x47_reservoir_390015.jpg similarity index 100% rename from pylabrobot/resources/porvair/ims/porvair_6x47_reservoir_390015.jpg rename to docs/resources/library/img/porvair/porvair_6x47_reservoir_390015.jpg diff --git a/pylabrobot/resources/revvity/ims/revvity_ProxiPlate-384-Plus-White-384-shallow-well-Microplate.jpg b/docs/resources/library/img/revvity/revvity_ProxiPlate-384-Plus-White-384-shallow-well-Microplate.jpg similarity index 100% rename from pylabrobot/resources/revvity/ims/revvity_ProxiPlate-384-Plus-White-384-shallow-well-Microplate.jpg rename to docs/resources/library/img/revvity/revvity_ProxiPlate-384-Plus-White-384-shallow-well-Microplate.jpg diff --git a/pylabrobot/resources/thermo_fisher/imgs/ThermoFisherMatrixTrough8094.jpg.avif b/docs/resources/library/img/thermo_fisher/ThermoFisherMatrixTrough8094.jpg.avif similarity index 100% rename from pylabrobot/resources/thermo_fisher/imgs/ThermoFisherMatrixTrough8094.jpg.avif rename to docs/resources/library/img/thermo_fisher/ThermoFisherMatrixTrough8094.jpg.avif diff --git a/pylabrobot/resources/thermo_fisher/imgs/Thermo_AB_96_wellplate_300ul_Vb_EnduraPlate.png b/docs/resources/library/img/thermo_fisher/Thermo_AB_96_wellplate_300ul_Vb_EnduraPlate.png similarity index 100% rename from pylabrobot/resources/thermo_fisher/imgs/Thermo_AB_96_wellplate_300ul_Vb_EnduraPlate.png rename to docs/resources/library/img/thermo_fisher/Thermo_AB_96_wellplate_300ul_Vb_EnduraPlate.png diff --git a/pylabrobot/resources/thermo_fisher/imgs/Thermo_TS_96_wellplate_1200ul_Rb.webp b/docs/resources/library/img/thermo_fisher/Thermo_TS_96_wellplate_1200ul_Rb.webp similarity index 100% rename from pylabrobot/resources/thermo_fisher/imgs/Thermo_TS_96_wellplate_1200ul_Rb.webp rename to docs/resources/library/img/thermo_fisher/Thermo_TS_96_wellplate_1200ul_Rb.webp diff --git a/docs/resources/library/ml_star.md b/docs/resources/library/ml_star.md new file mode 100644 index 0000000000..46af84620d --- /dev/null +++ b/docs/resources/library/ml_star.md @@ -0,0 +1,70 @@ +# Hamilton STAR "ML_STAR" + +Company history: [Hamilton Robotics history](https://www.hamiltoncompany.com/history) + +> Hamilton Robotics provides automated liquid handling workstations for the scientific community. Our portfolio includes three liquid handling platforms, small devices, consumables, and OEM solutions. + +## Carriers + +### Tip carriers + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'TIP_CAR_480_A00'
Part no.: 182085
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/182085)
Carrier for 5x 96 tip (10μl, 50μl, 300μl, 1000μl) racks or 5x 24 tip (5ml) racks (6T) | ![](img/ml_star/TIP_CAR_480_A00_182085.jpg) | `TIP_CAR_480_A00` | + +### Plate carriers + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'PLT_CAR_L5AC_A00'
Part no.: 182090
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/182090)
Carrier for 5x 96 Deep Well Plates or for 5x 384 tip racks (e.g.384HEAD_384TIPS_50μl) (6T) | ![](img/ml_star/PLT_CAR_L5AC_A00_182090.jpg) | `PLT_CAR_L5AC_A00` | + +### MFX carriers + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'MFX_CAR_L5_base'
Part no.: 188039
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188039)
Labware carrier base for up to 5 Multiflex Modules
Occupies 6 tracks (6T). | ![](img/ml_star/MFX_CAR_L5_base_188039.jpg) | `MFX_CAR_L5_base` | +| 'MFX_CAR_L4_SHAKER'
Part no.: 187001
[secondary supplier website](https://www.testmart.com/estore/unit.cfm/PIPPET/HAMROB/187001/automated_pippetting_devices_and_systems/8.html) (cannot find information on Hamilton website)
Sometimes referred to as "PLT_CAR_L4_SHAKER" by Hamilton.
Template carrier with 4 positions for Hamilton Heater Shaker.
Occupies 7 tracks (7T). Can be screwed onto the deck. | ![](img/ml_star/MFX_CAR_L4_SHAKER_187001.png) | `MFX_CAR_L4_SHAKER_187001` | + +### MFX modules + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'MFX_TIP_module'
Part no.: 188160 or 188040
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188040)
Module to position a high-, standard-, low volume or 5ml tip rack (but not a 384 tip rack) | ![](img/ml_star/MFX_TIP_module_188040.jpg) | `MFX_TIP_module` | +| 'MFX_DWP_rackbased_module'
Part no.: 188229?
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188229) (<-non-functional link?)
MFX DWP module rack-based | ![](img/ml_star/MFX_DWP_RB_module_188229_.jpg) | `MFX_DWP_rackbased_module` | + +### Tube carriers + +Sometimes called "sample carriers" in Hamilton jargon. + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'Tube_CAR_24_A00'
Part no.: 173400
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/173400)
Carries 24 "sample" tubes with 14.5–18 mm outer diameter, 60–120 mm high. Occupies one track. | ![](img/ml_star/Tube_CAR_24_A00.png) | `Tube_CAR_24_A00` | + +### Trough carriers + +Sometimes called "reagent carriers" in Hamilton jargon. + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'Trough_CAR_4R200_A00'
Part no.: 185436 (same as 96890-01?)
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/96890-01)
Trough carrier for 4x 200ml troughs. 2 tracks(T) wide. | ![](img/ml_star/Trough_CAR_4R200_A00.png) | `Trough_CAR_4R200_A00` | + +## Labware + +### TipRacks + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'TIP_50ul_L'
Formats:
- "50μL CO-RE Tips, sterile with filter": Part no.: [235979](https://www.hamiltoncompany.com/automated-liquid-handling/disposable-tips/50-%CE%BCl-conductive-sterile-filter-tips)
    • Filter=Filter
    • Sterile=Sterile
    • Tip Color (Conductivity)=Black (Conductive) | ![](img/ml_star/TIP_50ul_L.png) | `TIP_50ul_L` | +| 'Hamilton_96_tiprack_50ul_NTR'
Formats:
- "50μL CO-REII Tips, stacked NTRs, sterile": Part no.: [235987](https://www.hamiltoncompany.com/automated-liquid-handling/disposable-tips/50-%C2%B5l-nested-clear-sterile-tips)
    • Filter=Non-Filter
    • Sterile=Sterile
    • Tip Color (Conductivity)=Black (Conductive)
- "50uL CO-REII Nested Clear Tips": Part no.: [235964](https://www.hamiltoncompany.com/automated-liquid-handling/disposable-tips/50-%C2%B5l-nested-clear-tips)
    • Filter=Non-Filter
    • Sterile=Non-Sterile
    • Tip Color (Conductivity)=Clear (Non-Conductive)

Note: a **single** `NTR` is only **one rack**.
Multiple NTRs stacked on top of each other (as shown in the images on the right) are called a `TipStack`. | ![](img/ml_star/Hamilton_96_tiprack_50ul_NTR.png) ![](img/ml_star/Hamilton_96_tiprack_50ul_NTR_CLEAR.png) | `Hamilton_96_tiprack_50ul_NTR` | + +### Troughs + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'Hamilton_1_trough_200ml_Vb'
Part no.: 56695-02
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/56695-02)
Trough 200ml, w lid, self standing, Black.
Compatible with Trough_CAR_4R200_A00 (185436). | ![](img/ml_star/Hamilton_1_trough_200ml_Vb.jpg) | `Hamilton_1_trough_200ml_Vb` | + +## Adapters + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'Hamilton_96_adapter_188182'
Part no.: 188182
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188182) (<-non-functional link?)
Adapter for 96 well PCR plate, plunged. Does not have an ANSI/SLAS footprint -> requires assignment with specified location. | ![](img/ml_star/Hamilton_96_adapter_188182.png) | `Hamilton_96_adapter_188182` | diff --git a/docs/resources/library/opentrons.md b/docs/resources/library/opentrons.md new file mode 100644 index 0000000000..949f76c176 --- /dev/null +++ b/docs/resources/library/opentrons.md @@ -0,0 +1,88 @@ +# Opentrons + +Company page: [Opentrons Wikipedia](https://en.wikipedia.org/wiki/Opentrons) + +> Opentrons Labworks, Inc. (or Opentrons) is a biotechnology company that manufactures liquid handling robots that use open-source software, which at one point used open-source hardware but no longer does. + +NB: The [Opentrons Labware Library](https://labware.opentrons.com/) is a wonderful resource to see what Opentrons offers in terms of resources. + +We can automatically convert Opentrons resources to PLR resources using two methods in `pylabrobot.resources.opentrons`: + +- {func}`pylabrobot.resources.opentrons.load.load_opentrons_resource`: loading from a file +- {func}`pylabrobot.resources.opentrons.load.load_shared_opentrons_resource`: load from https://pypi.org/project/opentrons-shared-data/ (https://github.com/Opentrons/opentrons/tree/edge/shared-data) + +In addition, we provide convenience methods for loading many resources (see below). + +## Plates + +Note that Opentrons definitions typically lack information that is required to make them work on other robots. + +- `corning_384_wellplate_112ul_flat` +- `corning_96_wellplate_360ul_flat` +- `nest_96_wellplate_2ml_deep` +- `nest_96_wellplate_100ul_pcr_full_skirt` +- `appliedbiosystemsmicroamp_384_wellplate_40ul` +- `thermoscientificnunc_96_wellplate_2000ul` +- `usascientific_96_wellplate_2point4ml_deep` +- `thermoscientificnunc_96_wellplate_1300ul` +- `nest_96_wellplate_200ul_flat` +- `corning_6_wellplate_16point8ml_flat` +- `corning_24_wellplate_3point4ml_flat` +- `corning_12_wellplate_6point9ml_flat` +- `biorad_96_wellplate_200ul_pcr` +- `corning_48_wellplate_1point6ml_flat` +- `biorad_384_wellplate_50ul` + +## Tip racks + +- `eppendorf_96_tiprack_1000ul_eptips` +- `tipone_96_tiprack_200ul` +- `opentrons_96_tiprack_300ul` +- `opentrons_96_tiprack_10ul` +- `opentrons_96_filtertiprack_10ul` +- `geb_96_tiprack_10ul` +- `opentrons_96_filtertiprack_200ul` +- `eppendorf_96_tiprack_10ul_eptips` +- `opentrons_96_tiprack_1000ul` +- `opentrons_96_tiprack_20ul` +- `opentrons_96_filtertiprack_1000ul` +- `opentrons_96_filtertiprack_20ul` +- `geb_96_tiprack_1000ul` + +## Reservoirs + +- `agilent_1_reservoir_290ml` +- `axygen_1_reservoir_90ml` +- `nest_12_reservoir_15ml` +- `nest_1_reservoir_195ml` +- `nest_1_reservoir_290ml` +- `usascientific_12_reservoir_22ml` + +## Tube racks + +- `opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap` +- `opentrons_24_tuberack_eppendorf_2ml_safelock_snapcap_acrylic` +- `opentrons_6_tuberack_falcon_50ml_conical` +- `opentrons_15_tuberack_nest_15ml_conical` +- `opentrons_24_tuberack_nest_2ml_screwcap` +- `opentrons_24_tuberack_generic_0point75ml_snapcap_acrylic` +- `opentrons_10_tuberack_nest_4x50ml_6x15ml_conical` +- `opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical_acrylic` +- `opentrons_24_tuberack_nest_1point5ml_screwcap` +- `opentrons_24_tuberack_nest_1point5ml_snapcap` +- `opentrons_10_tuberack_falcon_4x50ml_6x15ml_conical` +- `opentrons_24_tuberack_nest_2ml_snapcap` +- `opentrons_24_tuberack_nest_0point5ml_screwcap` +- `opentrons_24_tuberack_eppendorf_1point5ml_safelock_snapcap` +- `opentrons_6_tuberack_nest_50ml_conical` +- `opentrons_15_tuberack_falcon_15ml_conical` +- `opentrons_24_tuberack_generic_2ml_screwcap` +- `opentrons_96_well_aluminum_block` +- `opentrons_24_aluminumblock_generic_2ml_screwcap` +- `opentrons_24_aluminumblock_nest_1point5ml_snapcap` + +## Plate Adapters + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'Opentrons_96_adapter_Vb'
Part no.: 999-00028 (one of the three adapters purchased in the "Aluminum Block Set")
[manufacturer website](https://opentrons.com/products/aluminum-block-set) | ![](imgs/Opentrons_96_adapter_Vb.jpg" alt="Opentrons_96_adapter_Vb" width="250"/> | `Opentrons_96_adapter_Vb` | diff --git a/pylabrobot/resources/porvair/README.md b/docs/resources/library/porvair.md similarity index 51% rename from pylabrobot/resources/porvair/README.md rename to docs/resources/library/porvair.md index a4d528c494..8d20653956 100644 --- a/pylabrobot/resources/porvair/README.md +++ b/docs/resources/library/porvair.md @@ -1,12 +1,11 @@ - -## Resource defintions: Porvair +# Porvair Company history: [Porvair Filtration Group](https://www.porvairfiltration.com/about/our-history/) > Porvair Filtration Group, a wholly owned subsidiary of Porvair plc, is a specialist filtration and environmental technology group involved in developing, designing and manufacturing filtration and separation solutions to industry sectors such as the aviation, molten metal, energy, water treatment and life sciences markets. Porvair plc is a publically owned company with four principal subsidiaries: Porvair Filtration Group Ltd., Porvair Sciences Ltd., Selee Corporation and Seal Analytical Ltd. -### Currently defined labware: +## Reservoirs | Description | Image | PLR definition | |--------------------|--------------------|--------------------| -| 'Porvair_6x47_Reservoir'
Part no.: 6008280
[manufacturer website](https://www.microplates.com/product/282-ml-reservoir-plate-6-columns-v-bottom/) | Porvair_6x47_Reservoir | `Porvair_6x47_Reservoir` | +| 'Porvair_6_reservoir_47ml_Vb'
Part no.: 6008280
[manufacturer website](https://www.microplates.com/product/282-ml-reservoir-plate-6-columns-v-bottom/)
- Material: Polypropylene
- Sterilization compatibility: Autoclaving (15 minutes at 121°C) or Gamma Irradiation
- Chemical resistance: "High chemical resistance"
- Temperature resistance: high: -196°C to + 121°C
- Cleanliness: 390015: Free of detectable DNase, RNase
- ANSI/SLAS-format for compatibility with automated systems
- Tolerances: "Uniform external dimensions and tolerances"| ![](img/porvair/porvair_6x47_reservoir_390015.jpg) | `Porvair_6_reservoir_47ml_Vb` | diff --git a/pylabrobot/resources/revvity/README.md b/docs/resources/library/revvity.md similarity index 82% rename from pylabrobot/resources/revvity/README.md rename to docs/resources/library/revvity.md index c11226ccd7..6ca88c0134 100644 --- a/pylabrobot/resources/revvity/README.md +++ b/docs/resources/library/revvity.md @@ -1,12 +1,11 @@ - -## Resource defintions: Revvity +# Revvity Company wikipedia: [Revvity, Inc. (formerly PerkinElmer, Inc.)](https://en.wikipedia.org/wiki/Revvity) > In 2022, a split of PerkinElmer resulted in one part, comprising its applied, food and enterprise services businesses, being sold to the private equity firm New Mountain Capital for $2.45 billion and thus no longer being public but keeping the PerkinElmer name. The other part, comprised of the life sciences and diagnostics businesses, remained public but required a new name, which in 2023 was announced as Revvity, Inc. From the perspective of Revvity, the goal of creating a separate company was that its businesses might show greater profit margins and more in the way of growth potential. An associated goal was to have more financial flexibility moving forward. On May 16, 2023, the PerkinElmer stock symbol PKI was replaced by the new symbol RVTY. -### Currently defined labware: +## Plates | Description | Image | PLR definition | |--------------------|--------------------|--------------------| -| 'Revvity_ProxiPlate_384Plus'
Part no.: 6008280
[manufacturer website](https://www.perkinelmer.com/uk/Product/proxiplate-384-plus-50w-6008280) | Revvity_ProxiPlate_384Plus | `Revvity_ProxiPlate_384Plus` +| 'Revvity_ProxiPlate_384Plus'
Part no.: 6008280
[manufacturer website](https://www.perkinelmer.com/uk/Product/proxiplate-384-plus-50w-6008280) | ![](img/revvity/revvity_ProxiPlate-384-Plus-White-384-shallow-well-Microplate.jpg) | `Revvity_ProxiPlate_384Plus` diff --git a/docs/resources/library/thermo_fisher.md b/docs/resources/library/thermo_fisher.md new file mode 100644 index 0000000000..043c937408 --- /dev/null +++ b/docs/resources/library/thermo_fisher.md @@ -0,0 +1,38 @@ +# Thermo Fisher Scientific Inc. + +Company page: [Thermo Fisher Scientific Inc. Wikipedia](https://en.wikipedia.org/wiki/Thermo_Fisher_Scientific) + +> Thermo Fisher Scientific Inc. is an American supplier of analytical instruments, life sciences solutions, specialty diagnostics, laboratory, pharmaceutical and biotechnology services. Based in Waltham, Massachusetts, Thermo Fisher was formed through the **merger of Thermo Electron and Fisher Scientific in 2006**. Thermo Fisher Scientific has acquired other reagent, consumable, instrumentation, and service providers, including Life Technologies Corporation (2013), Alfa Aesar (2015), Affymetrix (2016), FEI Company (2016), BD Advanced Bioprocessing (2018),and PPD (2021). + +A basic structure of the companiy, [its brands](https://www.thermofisher.com/uk/en/home/brands.html) and product lines looks like this: + +``` +Thermo Fisher Scientific Inc. (TFS, aka "Thermo") +├── Applied Biosystems (AB; brand) +│ └── MicroAmp +│ └── EnduraPlate +├── Fisher Scientific (FS; brand) +├── Invitrogen (INV; brand) +├── Ion Torrent (IT; brand) +├── Gibco (GIB; brand) +├── Thermo Scientific (TS; brand) +│ ├── Nalgene +│ ├── Nunc +│ └── Pierce +├── Unity Lab Services (brand, services) +├── Patheon (brand, services) +└── PPD (brand, services) +``` + +## Plates + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'Thermo_TS_96_wellplate_1200ul_Rb'
Part no.: AB-1127 or 10243223
[manufacturer website](https://www.fishersci.co.uk/shop/products/product/10243223)

- Material: Polypropylene (AB-1068, polystyrene)
| ![](img/thermo_fisher/Thermo_TS_96_wellplate_1200ul_Rb.webp) | `Thermo_TS_96_wellplate_1200ul_Rb` | +| 'Thermo_AB_96_wellplate_300ul_Vb_EnduraPlate'
Part no.: 4483354 (TFS) or 15273005 (FS) (= with barcode)
Part no.: 16698853 (FS) (= **without** barcode)
[manufacturer website](https://www.thermofisher.com/order/catalog/product/4483354)

- Material: Polycarbonate, Polypropylene
- plate_type: semi-skirted
- product line: "MicroAmp"
- (sub)product line: "EnduraPlate" | ![](img/thermo_fisher/Thermo_AB_96_wellplate_300ul_Vb_EnduraPlate.png) | `Thermo_AB_96_wellplate_300ul_Vb_EnduraPlate` | + +## Troughs + +| Description | Image | PLR definition | +|--------------------|--------------------|--------------------| +| 'ThermoFisherMatrixTrough8094'
Part no.: 8094
[manufacturer website](https://www.thermofisher.com/order/catalog/product/8094) | ![](img/thermo_fisher/ThermoFisherMatrixTrough8094.jpg.avif) | `ThermoFisherMatrixTrough8094` | diff --git a/docs/resources/plate_carriers.md b/docs/resources/plate_carriers.md index 8f6673bcff..4457247895 100644 --- a/docs/resources/plate_carriers.md +++ b/docs/resources/plate_carriers.md @@ -12,8 +12,8 @@ The pedestal information is not typically available in labware databases (like t Here's how you measure the pedestal height: -![Pedestal height measurement](/img/pedestal/measure.jpeg) +![Pedestal height measurement](/resources/img/pedestal/measure.jpeg) -Once you have measured the pedestal height, you can contribute this information to the PyLabRobot Labware database. Here's a guide on contributing to the open-source project: ["How to Open Source"](/how-to-open-source.md). +Once you have measured the pedestal height, you can contribute this information to the PyLabRobot Labware database. Here's a guide on contributing to the open-source project: ["How to Open Source"](/contributor_guide/how-to-open-source.md). For background, see PR 143: [https://github.com/PyLabRobot/pylabrobot/pull/143](https://github.com/PyLabRobot/pylabrobot/pull/143). diff --git a/docs/resources/plates.md b/docs/resources/plates.md index 93aed790d6..13ac4aebde 100644 --- a/docs/resources/plates.md +++ b/docs/resources/plates.md @@ -1,6 +1,6 @@ # Plates -Microplates are modelled by the {class}`~pylabrobot.resources.plate.Plate` class consist of equally spaced wells. Wells are children of the `Plate` and are modelled by the {class}`~pylabrobot.resources.well.Well` class. The relative positioning of `Well`s is what determines their location. `Plate` is a subclass of {class}`~pylabrobot.resources.itemized_resource.ItemizedResource`, allowing convenient integer and string indexing. +Microplates are modelled by the {class}`~pylabrobot.resources.plate.Plate` class consisting of equally spaced wells. Wells are children of the `Plate` and are modelled by the {class}`~pylabrobot.resources.well.Well` class. The relative positioning of `Well`s is what determines their location. `Plate` is a subclass of {class}`~pylabrobot.resources.itemized_resource.ItemizedResource`, allowing convenient integer and string indexing. There is some standardization on plate dimensions by SLAS, which you can read more about in the [ANSI SLAS 1-2004 (R2012): Footprint Dimensions doc](https://www.slas.org/SLAS/assets/File/public/standards/ANSI_SLAS_1-2004_FootprintDimensions.pdf). Note that PLR fully supports all plate dimensions, sizes, relative well spacings, etc. @@ -12,4 +12,4 @@ Plates can optionally have a lid, which will also be a child of the `Plate` clas The `nesting_z_height` is the overlap between the lid and the plate when the lid is placed on the plate. This property can be measured using a caliper. -![nesting_z_height measurement](/img/plate/lid_nesting_z_height.jpeg) +![nesting_z_height measurement](/resources/img/plate/lid_nesting_z_height.jpeg) diff --git a/docs/basic.ipynb b/docs/user_guide/basic.ipynb similarity index 97% rename from docs/basic.ipynb rename to docs/user_guide/basic.ipynb index ed3cff2d9b..2df3a22c9e 100644 --- a/docs/basic.ipynb +++ b/docs/user_guide/basic.ipynb @@ -4,13 +4,13 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Basic liquid handling\n", + "# Getting started with liquid handling on a Hamilton STAR(let)\n", "\n", "In this notebook, you will learn how to use PyLabRobot to move water from one range of wells to another.\n", "\n", "**Note: before running this notebook, you should have**:\n", "\n", - "- Installed PyLabRobot and the USB driver as described in [the installation guide](installation).\n", + "- Installed PyLabRobot and the USB driver as described in [the installation guide](/user_guide/installation).\n", "- Connected the Hamilton to your computer using the USB cable.\n", "\n", "Video of what this code does:\n", @@ -115,7 +115,7 @@ "\n", "- {class}`~pylabrobot.resources.ml_star.tip_carriers.TIP_CAR_480_A00` tip carrier\n", "- {class}`~pylabrobot.resources.ml_star.plate_carriers.PLT_CAR_L5AC_A00` plate carrier\n", - "- {class}`~pylabrobot.resources.corning_costar.plates.Cos_96_DW_1mL` wells\n", + "- {class}`~pylabrobot.resources.corning_costar.plates.Cor_96_wellplate_360ul_Fb` wells\n", "- {class}`~pylabrobot.resources.ml_star.tip_racks.HTF_L` tips" ] }, @@ -128,7 +128,7 @@ "from pylabrobot.resources import (\n", " TIP_CAR_480_A00,\n", " PLT_CAR_L5AC_A00,\n", - " Cos_96_DW_1mL,\n", + " Cor_96_wellplate_360ul_Fb,\n", " HTF_L\n", ")" ] @@ -217,7 +217,7 @@ "source": [ "## CHANGE TIP CARRIERS TO CORRECT SIZE\n", "plt_car = PLT_CAR_L5AC_A00(name='plate carrier')\n", - "plt_car[0] = Cos_96_DW_1mL(name='plate_01')" + "plt_car[0] = Cor_96_wellplate_360ul_Fb(name='plate_01')" ] }, { diff --git a/docs/user_guide/configuration.md b/docs/user_guide/configuration.md new file mode 100644 index 0000000000..8fb9a63662 --- /dev/null +++ b/docs/user_guide/configuration.md @@ -0,0 +1,81 @@ +# Configuring PLR + +The `pylabrobot.config` module provides the `Config` class for configuring PLR. The configuration can be set programmatically or loaded from a file. + +The configuration currently only supports logging configuration. + +## The `Config` class + +You can create a `Config` object as follows: + +```python +import logging +from pathlib import Path +from pylabrobot.config import Config + +config = Config( + logging=Config.Logging( + level=logging.DEBUG, + log_dir=Path("my_logs") + ) +) +``` + +Then, call `pylabrobot.configure` to apply the configuration: + +```python +import pylabrobot +pylabrobot.configure(config) +``` + +## Loading from a file + +PLR supports loading configuration from a number of file formats. The supported formats are: + +- INI files +- JSON files + +Files are loaded using the `pylabrobot.config.load_config` function: + +```python +from pylabrobot.config import load_config +config = load_config("config.json") + +import pylabrobot +pylabrobot.configure(config) +``` + +If no file is found, a default configuration is used. + +`load_config` has the following parameters: + +```python +def load_config( + base_file_name: str, + create_default: bool = False, + create_module_level: bool = True +) -> Config: +``` + +A `pylabrobot.ini` file is used if found in the current directory. If not found, it is searched for in all parent directories. If it still is not found, it gets created at either the project level that contains the `.git` directory, or the current directory. + +### INI files + +Example of an INI file: + +```ini +[logging] +level = DEBUG +log_dir = . +``` + +### JSON files + +```json +{ + "logging": { + "level": "DEBUG", + "log_dir": "." + } +} +``` diff --git a/docs/user_guide/cytation5.ipynb b/docs/user_guide/cytation5.ipynb new file mode 100644 index 0000000000..6a3157a821 --- /dev/null +++ b/docs/user_guide/cytation5.ipynb @@ -0,0 +1,245 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Cytation 5" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "from pylabrobot.plate_reading import PlateReader, Cytation5Backend" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "pr = PlateReader(name=\"PR\", size_x=0,size_y=0,size_z=0, backend=Cytation5Backend())\n", + "await pr.setup()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'1320200 Version 2.07'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await pr.backend.get_firmware_version()" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "22.9" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "await pr.backend.get_current_temperature()" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "await pr.open()" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "await pr.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Plate reading" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAhYAAAF2CAYAAAAyW9EUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAaZ0lEQVR4nO3dfZDVdf338ffeyNkVl1WQu/2xKJqliJiKMkqlJuow5mTN0M1gEc7UVbOkyNTY1qXWmK76mxpLHbwZw+YqxJoJKyd1lBSnSZSb6NIs1KRYbwD10l1Yc4E95/rjN+2v/SWcPctn+Z4vPh4z3z/28D18Xx2BfXbOgVNTKpVKAQCQQG3WAwCAA4ewAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZOr39wWLxWK88sor0dTUFDU1Nfv78gDAEJRKpdi+fXu0tLREbe2en5fY72HxyiuvRGtr6/6+LACQQGdnZ0yaNGmPP77fw6KpqSkiIlauHhcjD6neV2Iu/j+XZj1hUI6856WsJ5RVPGRk1hPKqikWs54wKDX/eCfrCWX1HT4q6wll1fxjd9YTBmX3oQ1ZTyir6+jq37j9nJ6sJwzKujOWZz1hr7p3FOOIk//W/318T/Z7WPzz5Y+Rh9TGIU3VGxZ1her/zRIRUV9byHpCWcW66t9YU5OTsKit/o/2qamr/t87NXW7sp4wOPXV/1jWjaj+jbUH92U9YVBGVfH3xH9V7m0M+fhfAQDkgrAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkMKSxuvfXWOPLII6OhoSFmzpwZTz31VOpdAEAOVRwW9957byxevDiuvvrqWL9+fZx44olx/vnnx7Zt24ZjHwCQIxWHxfe///344he/GAsWLIipU6fGbbfdFgcffHD86Ec/Go59AECOVBQWO3fujHXr1sXs2bP/+yeorY3Zs2fHE0888a736e3tje7u7gEHAHBgqigsXn/99ejr64vx48cPuH38+PGxZcuWd71PR0dHNDc39x+tra1DXwsAVLVh/1sh7e3t0dXV1X90dnYO9yUBgIzUV3Ly4YcfHnV1dbF169YBt2/dujUmTJjwrvcpFApRKBSGvhAAyI2KnrEYMWJEnHLKKbFy5cr+24rFYqxcuTJOP/305OMAgHyp6BmLiIjFixfH/PnzY8aMGXHaaafFTTfdFD09PbFgwYLh2AcA5EjFYfHpT386Xnvttbjqqqtiy5Yt8cEPfjAefPDBf3tDJwDw3lNxWERELFy4MBYuXJh6CwCQcz4rBABIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSG9OmmKbxdqo/aUvV2zX889o+sJwzKzsmHZz2hrFJ9TdYTynrjuIasJwzKoS/uynpCWSMeXJP1hLLqJozPesKg1B1Ul/WEssb8cWfWE8pqfGNk1hMG5byb5mc9Ya92734nIq4te171fmcHAHJHWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASKbisHj88cfjwgsvjJaWlqipqYn77rtvGGYBAHlUcVj09PTEiSeeGLfeeutw7AEAcqy+0jvMmTMn5syZMxxbAICcqzgsKtXb2xu9vb39X3d3dw/3JQGAjAz7mzc7Ojqiubm5/2htbR3uSwIAGRn2sGhvb4+urq7+o7Ozc7gvCQBkZNhfCikUClEoFIb7MgBAFfDvWAAAyVT8jMWOHTvihRde6P9606ZNsWHDhhg9enRMnjw56TgAIF8qDou1a9fG2Wef3f/14sWLIyJi/vz5cffddycbBgDkT8VhcdZZZ0WpVBqOLQBAznmPBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMlU/OmmqVxx5f+K+oMasrp8WTuPykdzHf77rVlPKKvUOCLrCWVNfHR71hMGpzYHvy6POybrBWXtOuzgrCcMykF/fy3rCWXtPGp81hPKanitN+sJg7LpE9X967L4Tm3EU+XPy8GfUgBAXggLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASKaisOjo6IhTTz01mpqaYty4cXHRRRfFxo0bh2sbAJAzFYXFqlWroq2tLVavXh0PP/xw7Nq1K84777zo6ekZrn0AQI7UV3Lygw8+OODru+++O8aNGxfr1q2Lj3zkI0mHAQD5U1FY/E9dXV0RETF69Og9ntPb2xu9vb39X3d3d+/LJQGAKjbkN28Wi8VYtGhRzJo1K6ZNm7bH8zo6OqK5ubn/aG1tHeolAYAqN+SwaGtri2eeeSaWL1++1/Pa29ujq6ur/+js7BzqJQGAKjekl0IWLlwY999/fzz++OMxadKkvZ5bKBSiUCgMaRwAkC8VhUWpVIqvfvWrsWLFinjsscdiypQpw7ULAMihisKira0tli1bFr/85S+jqakptmzZEhERzc3N0djYOCwDAYD8qOg9FkuWLImurq4466yzYuLEif3HvffeO1z7AIAcqfilEACAPfFZIQBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACRT0aebptQ1tyfqDt6d1eXLmvy/d2U9YVDefv+YrCeU1fjSjqwnlNXX1JD1hMGprcl6QVm7Dz4o6wlljXi9J+sJg1I65OCsJ5Q1ovONrCeUVRrZmPWEQRm3trp37t5Vir8N4jzPWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASKaisFiyZElMnz49Ro0aFaNGjYrTTz89HnjggeHaBgDkTEVhMWnSpLj++utj3bp1sXbt2vjoRz8aH//4x+NPf/rTcO0DAHKkvpKTL7zwwgFfX3vttbFkyZJYvXp1HH/88UmHAQD5U1FY/Ku+vr74+c9/Hj09PXH66afv8bze3t7o7e3t/7q7u3uolwQAqlzFb958+umn45BDDolCoRBf/vKXY8WKFTF16tQ9nt/R0RHNzc39R2tr6z4NBgCqV8Vh8YEPfCA2bNgQTz75ZHzlK1+J+fPnx7PPPrvH89vb26Orq6v/6Ozs3KfBAED1qvilkBEjRsT73ve+iIg45ZRTYs2aNfGDH/wgbr/99nc9v1AoRKFQ2LeVAEAu7PO/Y1EsFge8hwIAeO+q6BmL9vb2mDNnTkyePDm2b98ey5Yti8ceeyweeuih4doHAORIRWGxbdu2+PznPx+vvvpqNDc3x/Tp0+Ohhx6Kc889d7j2AQA5UlFY3HXXXcO1AwA4APisEAAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJKp6NNNUzp8aUPU1zdkdfnyXn0l6wWD0lhf/W1YbDgo6wll1W3akvWEQdn9vpasJ5T12gcLWU8oq/Wel7OeMChdZxyR9YSyXr1oZ9YTyjpkfWPWEwalZ1Ix6wl7VXwnIn5R/rzq/64EAOSGsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkMw+hcX1118fNTU1sWjRokRzAIA8G3JYrFmzJm6//faYPn16yj0AQI4NKSx27NgR8+bNizvvvDMOO+yw1JsAgJwaUli0tbXFBRdcELNnzy57bm9vb3R3dw84AIADU32ld1i+fHmsX78+1qxZM6jzOzo64jvf+U7FwwCA/KnoGYvOzs647LLL4qc//Wk0NDQM6j7t7e3R1dXVf3R2dg5pKABQ/Sp6xmLdunWxbdu2OPnkk/tv6+vri8cffzxuueWW6O3tjbq6ugH3KRQKUSgU0qwFAKpaRWFxzjnnxNNPPz3gtgULFsSxxx4bV1xxxb9FBQDw3lJRWDQ1NcW0adMG3DZy5MgYM2bMv90OALz3+Jc3AYBkKv5bIf/TY489lmAGAHAg8IwFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyezzp5sO1eT252PEISOyunxZWxdMyHrCoOwcOzLrCWUVXunOekJZXWcfnfWEQdk+qfr/v8Ck+7dmPaGsNz56ZNYTBmX0r5/NekJZB786JesJ5ZV6sl4wKG//R0PWE/Zq965S/H0Q51X/n1IAQG4ICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEimorD49re/HTU1NQOOY489dri2AQA5U1/pHY4//vh45JFH/vsnqK/4pwAADlAVV0F9fX1MmDBhOLYAADlX8Xssnn/++WhpaYmjjjoq5s2bF5s3b97r+b29vdHd3T3gAAAOTBWFxcyZM+Puu++OBx98MJYsWRKbNm2KD3/4w7F9+/Y93qejoyOam5v7j9bW1n0eDQBUp4rCYs6cOTF37tyYPn16nH/++fGb3/wm3nrrrfjZz362x/u0t7dHV1dX/9HZ2bnPowGA6rRP77w89NBD4/3vf3+88MILezynUChEoVDYl8sAADmxT/+OxY4dO+Kvf/1rTJw4MdUeACDHKgqLr33ta7Fq1ar429/+Fr///e/jE5/4RNTV1cVnP/vZ4doHAORIRS+FvPTSS/HZz3423njjjRg7dmx86EMfitWrV8fYsWOHax8AkCMVhcXy5cuHawcAcADwWSEAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkU9Gnm6b0xBNTo7ahIavLl3VM3ZtZTxiUEa+/nfWE8nbtznpBWbsaa7KeMCijNvdlPaGsmn/0Zj2hrIb/V/2PY0TE27M+kPWEsup6i1lPKOsf4w7KesKgbJ2Z9YK9K75TE3Ff+fM8YwEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIJmKw+Lll1+Oiy++OMaMGRONjY1xwgknxNq1a4djGwCQM/WVnPzmm2/GrFmz4uyzz44HHnggxo4dG88//3wcdthhw7UPAMiRisLihhtuiNbW1li6dGn/bVOmTEk+CgDIp4peCvnVr34VM2bMiLlz58a4cePipJNOijvvvHOv9+nt7Y3u7u4BBwBwYKooLF588cVYsmRJHHPMMfHQQw/FV77ylbj00kvjxz/+8R7v09HREc3Nzf1Ha2vrPo8GAKpTRWFRLBbj5JNPjuuuuy5OOumk+NKXvhRf/OIX47bbbtvjfdrb26Orq6v/6Ozs3OfRAEB1qigsJk6cGFOnTh1w23HHHRebN2/e430KhUKMGjVqwAEAHJgqCotZs2bFxo0bB9z23HPPxRFHHJF0FACQTxWFxeWXXx6rV6+O6667Ll544YVYtmxZ3HHHHdHW1jZc+wCAHKkoLE499dRYsWJF3HPPPTFt2rS45ppr4qabbop58+YN1z4AIEcq+ncsIiI+9rGPxcc+9rHh2AIA5JzPCgEAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJFPxx6anUqorRamulNXly9r45easJwzKYf+3+ttwwkMvZT2hrEOfezvrCYNS/+e/ZT2hvEOr//dO48vbs54wKLVdPVlPKK+uLusFZW09tSXrCYNSO77K/xx6+51BnVb935UAgNwQFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJBMRWFx5JFHRk1Nzb8dbW1tw7UPAMiR+kpOXrNmTfT19fV//cwzz8S5554bc+fOTT4MAMifisJi7NixA76+/vrr4+ijj44zzzwz6SgAIJ8qCot/tXPnzvjJT34Sixcvjpqamj2e19vbG729vf1fd3d3D/WSAECVG/KbN++7775466234gtf+MJez+vo6Ijm5ub+o7W1daiXBACq3JDD4q677oo5c+ZES0vLXs9rb2+Prq6u/qOzs3OolwQAqtyQXgr5+9//Ho888kj84he/KHtuoVCIQqEwlMsAADkzpGcsli5dGuPGjYsLLrgg9R4AIMcqDotisRhLly6N+fPnR339kN/7CQAcgCoOi0ceeSQ2b94cl1xyyXDsAQByrOKnHM4777wolUrDsQUAyDmfFQIAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAktnvn3v+zw8wK77zzv6+dEVKffn4oLW+ndXfhruLvVlPKGv37ur+9divtDPrBWXV5OC/d7GvLusJg1Kbg8cyaqr/sezrzcfv7+Lb1b2z+I//+vVY7oNIa0r7+aNKX3rppWhtbd2flwQAEuns7IxJkybt8cf3e1gUi8V45ZVXoqmpKWpqavb55+vu7o7W1tbo7OyMUaNGJVj43uWxTMdjmYbHMR2PZTrv1ceyVCrF9u3bo6WlJWpr9/xs+X5/KaS2tnavpTNUo0aNek/9Bx5OHst0PJZpeBzT8Vim8158LJubm8ueU/0v0AMAuSEsAIBkch8WhUIhrr766igUCllPyT2PZToeyzQ8jul4LNPxWO7dfn/zJgBw4Mr9MxYAQPUQFgBAMsICAEhGWAAAyeQ+LG699dY48sgjo6GhIWbOnBlPPfVU1pNyp6OjI0499dRoamqKcePGxUUXXRQbN27MelbuXX/99VFTUxOLFi3Kekouvfzyy3HxxRfHmDFjorGxMU444YRYu3Zt1rNypa+vL6688sqYMmVKNDY2xtFHHx3XXHNN2c96IOLxxx+PCy+8MFpaWqKmpibuu+++AT9eKpXiqquuiokTJ0ZjY2PMnj07nn/++WzGVplch8W9994bixcvjquvvjrWr18fJ554Ypx//vmxbdu2rKflyqpVq6KtrS1Wr14dDz/8cOzatSvOO++86OnpyXpabq1ZsyZuv/32mD59etZTcunNN9+MWbNmxUEHHRQPPPBAPPvss/G9730vDjvssKyn5coNN9wQS5YsiVtuuSX+/Oc/xw033BA33nhj3HzzzVlPq3o9PT1x4oknxq233vquP37jjTfGD3/4w7jtttviySefjJEjR8b5558f71T5B2zuF6UcO+2000ptbW39X/f19ZVaWlpKHR0dGa7Kv23btpUiorRq1aqsp+TS9u3bS8ccc0zp4YcfLp155pmlyy67LOtJuXPFFVeUPvShD2U9I/cuuOCC0iWXXDLgtk9+8pOlefPmZbQonyKitGLFiv6vi8ViacKECaX//M//7L/trbfeKhUKhdI999yTwcLqkttnLHbu3Bnr1q2L2bNn999WW1sbs2fPjieeeCLDZfnX1dUVERGjR4/OeEk+tbW1xQUXXDDg1yaV+dWvfhUzZsyIuXPnxrhx4+Kkk06KO++8M+tZuXPGGWfEypUr47nnnouIiD/+8Y/xu9/9LubMmZPxsnzbtGlTbNmyZcDv8ebm5pg5c6bvP5HBh5Cl8vrrr0dfX1+MHz9+wO3jx4+Pv/zlLxmtyr9isRiLFi2KWbNmxbRp07KekzvLly+P9evXx5o1a7KekmsvvvhiLFmyJBYvXhzf/OY3Y82aNXHppZfGiBEjYv78+VnPy41vfOMb0d3dHccee2zU1dVFX19fXHvttTFv3rysp+Xali1bIiLe9fvPP3/svSy3YcHwaGtri2eeeSZ+97vfZT0ldzo7O+Oyyy6Lhx9+OBoaGrKek2vFYjFmzJgR1113XUREnHTSSfHMM8/EbbfdJiwq8LOf/Sx++tOfxrJly+L444+PDRs2xKJFi6KlpcXjyLDJ7Ushhx9+eNTV1cXWrVsH3L5169aYMGFCRqvybeHChXH//ffHo48+OiwfbX+gW7duXWzbti1OPvnkqK+vj/r6+li1alX88Ic/jPr6+ujr68t6Ym5MnDgxpk6dOuC24447LjZv3pzRonz6+te/Ht/4xjfiM5/5TJxwwgnxuc99Li6//PLo6OjIelqu/fN7jO8/7y63YTFixIg45ZRTYuXKlf23FYvFWLlyZZx++ukZLsufUqkUCxcujBUrVsRvf/vbmDJlStaTcumcc86Jp59+OjZs2NB/zJgxI+bNmxcbNmyIurq6rCfmxqxZs/7trzw/99xzccQRR2S0KJ/efvvtqK0d+Md8XV1dFIvFjBYdGKZMmRITJkwY8P2nu7s7nnzySd9/IucvhSxevDjmz58fM2bMiNNOOy1uuumm6OnpiQULFmQ9LVfa2tpi2bJl8ctf/jKampr6XyNsbm6OxsbGjNflR1NT07+9L2XkyJExZswY71ep0OWXXx5nnHFGXHfddfGpT30qnnrqqbjjjjvijjvuyHparlx44YVx7bXXxuTJk+P444+PP/zhD/H9738/LrnkkqynVb0dO3bECy+80P/1pk2bYsOGDTF69OiYPHlyLFq0KL773e/GMcccE1OmTIkrr7wyWlpa4qKLLspudLXI+q+l7Kubb765NHny5NKIESNKp512Wmn16tVZT8qdiHjXY+nSpVlPyz1/3XTofv3rX5emTZtWKhQKpWOPPbZ0xx13ZD0pd7q7u0uXXXZZafLkyaWGhobSUUcdVfrWt75V6u3tzXpa1Xv00Uff9c/F+fPnl0ql//orp1deeWVp/PjxpUKhUDrnnHNKGzduzHZ0lfCx6QBAMrl9jwUAUH2EBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDL/H7tp0rS3+ToTAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "data = await pr.read_absorbance(wavelength=434)\n", + "plt.imshow(data)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAhYAAAF2CAYAAAAyW9EUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAaQUlEQVR4nO3dfZDVdf338ffZXTmQLasgd3uxKN6UNwipiKPYL03Uiwu5smasHCzSGZucJUWmLt0atcZk1a4c82ZAHdNmEm+aK9Sc1FFSGK9EEaNLs1CSYtWA7JJdWHXBPef645r299ufwtmzfJbvfvHxmPn+cQ7fw/fV0Xafnj1wCuVyuRwAAAnUZD0AANh7CAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEimbk9fsFQqxVtvvRX19fVRKBT29OUBgH4ol8uxdevWaGxsjJqanb8uscfD4q233oqmpqY9fVkAIIG2trYYP378Tn99j4dFfX19RER8tva/R11hnz19+T6rOeTArCf0zTsdWS+orKE+6wUVvfHfRmU9oU/G/3pj1hMqKnRtz3pCZXW1WS/ok62TxmQ9oaJ912/NekJFhbcG//9vIiLK23dkPWGXPijviBXv/a+e7+M7s8fD4l8//qgr7DO4w6K2mPWEvqkZkvWCynLwXNYWh2Y9oU/qcvBcFmpy8CPOmnyERd0+g//fy7rawR+ShUIOvk5GRDknbw+o9DYGb94EAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgmX6Fxa233hoHHXRQDB06NE444YR4/vnnU+8CAHKo6rC4//77Y8GCBXHVVVfFiy++GFOmTIkzzzwzNm/ePBD7AIAcqTosbrjhhrjwwgvj/PPPjyOPPDIWL14cn/jEJ+JnP/vZQOwDAHKkqrDYvn17rF69OmbMmPHvv0FNTcyYMSOeffbZj3xMV1dXdHR09DoAgL1TVWHx9ttvR3d3d4wZM6bX/WPGjImNGzd+5GNaW1ujoaGh52hqaur/WgBgUBvwPxXS0tIS7e3tPUdbW9tAXxIAyEhdNScfcMABUVtbG5s2bep1/6ZNm2Ls2LEf+ZhisRjFYrH/CwGA3KjqFYshQ4bEcccdF8uWLeu5r1QqxbJly+LEE09MPg4AyJeqXrGIiFiwYEHMnTs3pk6dGtOmTYsbb7wxOjs74/zzzx+IfQBAjlQdFl/5ylfiH//4R1x55ZWxcePG+MxnPhOPPfbYh97QCQB8/FQdFhER8+bNi3nz5qXeAgDknM8KAQCSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIJl+fbppCq/ddHTUDBua1eUrOmL+q1lP6JPCJ4ZlPaGi8paOrCdUNO6ZfbOesNfo3rgp6wkV1TQMz3pCn3ziN2uynlDZPpl9G+mzdT87NOsJfXLwea9kPWGXyuUdfTrPKxYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMlWHxYoVK2L27NnR2NgYhUIhHnzwwQGYBQDkUdVh0dnZGVOmTIlbb711IPYAADlWV+0DZs6cGTNnzhyILQBAzlUdFtXq6uqKrq6untsdHR0DfUkAICMD/ubN1tbWaGho6DmampoG+pIAQEYGPCxaWlqivb2952hraxvoSwIAGRnwH4UUi8UoFosDfRkAYBDw91gAAMlU/YrFtm3bYt26dT23169fH2vWrIkRI0bEhAkTko4DAPKl6rB44YUX4tRTT+25vWDBgoiImDt3btx9993JhgEA+VN1WJxyyilRLpcHYgsAkHPeYwEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAyVX+6aSqf/p//N+pqi1ldvrIh+2S9oE+6//lO1hMqKtQO/n6t/T+dWU/ok9IHH2Q9oaKa/RqynlBZdynrBX2Sh+ey1NGR9YSKDm1+I+sJfVIeNizrCbtUKNdGbK183uD/ig8A5IawAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSqCovW1tY4/vjjo76+PkaPHh1nn312rF27dqC2AQA5U1VYLF++PJqbm2PlypXxxBNPxI4dO+KMM86Izs7OgdoHAORIXTUnP/bYY71u33333TF69OhYvXp1/Nu//VvSYQBA/lQVFv9Ze3t7RESMGDFip+d0dXVFV1dXz+2Ojo7duSQAMIj1+82bpVIp5s+fH9OnT49Jkybt9LzW1tZoaGjoOZqamvp7SQBgkOt3WDQ3N8fLL78c99133y7Pa2lpifb29p6jra2tv5cEAAa5fv0oZN68efHII4/EihUrYvz48bs8t1gsRrFY7Nc4ACBfqgqLcrkc3/72t2Pp0qXx9NNPx8SJEwdqFwCQQ1WFRXNzcyxZsiQeeuihqK+vj40bN0ZERENDQwwbNmxABgIA+VHVeywWLVoU7e3tccopp8S4ceN6jvvvv3+g9gEAOVL1j0IAAHbGZ4UAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQTFWfbprUlo6ImiGZXb6Scue7WU/ok5pDD8x6QkXdf16X9YSK6iaMz3pCn5TffT/rCRUVagf/f6+UtrVnPaFvamuzXlBRefv2rCdUVH5v8D+PERGldwf3951SeUefzhv8XwEAgNwQFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJBMVWGxaNGimDx5cgwfPjyGDx8eJ554Yjz66KMDtQ0AyJmqwmL8+PFx7bXXxurVq+OFF16Iz3/+8/GFL3wh/vjHPw7UPgAgR+qqOXn27Nm9bl9zzTWxaNGiWLlyZRx11FFJhwEA+VNVWPxH3d3d8ctf/jI6OzvjxBNP3Ol5XV1d0dXV1XO7o6Ojv5cEAAa5qt+8+dJLL8UnP/nJKBaL8a1vfSuWLl0aRx555E7Pb21tjYaGhp6jqalptwYDAINX1WHx6U9/OtasWRPPPfdcXHTRRTF37tx45ZVXdnp+S0tLtLe39xxtbW27NRgAGLyq/lHIkCFD4tBDD42IiOOOOy5WrVoVP/3pT+O22277yPOLxWIUi8XdWwkA5MJu/z0WpVKp13soAICPr6pesWhpaYmZM2fGhAkTYuvWrbFkyZJ4+umn4/HHHx+ofQBAjlQVFps3b46vf/3r8fe//z0aGhpi8uTJ8fjjj8fpp58+UPsAgBypKizuvPPOgdoBAOwFfFYIAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyVT16aYplTrfjVJhR1aXr6y7O+sFfVKzZWvWEyqqHbF/1hMqKu33yawn9M0//pn1gor+0jIl6wkVHbr4b1lP6JOOaU1ZT6ho+PNtWU+oqPzue1lP6JO6USOznrBrpa6IDZVP84oFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkMxuhcW1114bhUIh5s+fn2gOAJBn/Q6LVatWxW233RaTJ09OuQcAyLF+hcW2bdtizpw5cccdd8T++++fehMAkFP9Covm5uaYNWtWzJgxo+K5XV1d0dHR0esAAPZOddU+4L777osXX3wxVq1a1afzW1tb44c//GHVwwCA/KnqFYu2tra45JJL4p577omhQ4f26TEtLS3R3t7ec7S1tfVrKAAw+FX1isXq1atj8+bNceyxx/bc193dHStWrIhbbrklurq6ora2ttdjisViFIvFNGsBgEGtqrA47bTT4qWXXup13/nnnx+HH354XHbZZR+KCgDg46WqsKivr49Jkyb1um/fffeNkSNHfuh+AODjx9+8CQAkU/WfCvnPnn766QQzAIC9gVcsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASGa3P920v2om/JeoqS1mdfmKyn97I+sJfVMckvWCit6b0pT1hIqGrX8n6wl98pt1v8t6QkWzTjog6wkVlbduy3pCnwx/9q9ZT6io+50tWU+oqDBk8H+djIgo1Az2/9bv277B/r8CAMgRYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJVBUWP/jBD6JQKPQ6Dj/88IHaBgDkTF21DzjqqKPiySef/PffoK7q3wIA2EtVXQV1dXUxduzYgdgCAORc1e+xeO2116KxsTEOPvjgmDNnTmzYsGGX53d1dUVHR0evAwDYO1UVFieccELcfffd8dhjj8WiRYti/fr18dnPfja2bt2608e0trZGQ0NDz9HU1LTbowGAwamqsJg5c2acc845MXny5DjzzDPjN7/5TWzZsiUeeOCBnT6mpaUl2tvbe462trbdHg0ADE679c7L/fbbLz71qU/FunXrdnpOsViMYrG4O5cBAHJit/4ei23btsVf/vKXGDduXKo9AECOVRUW3/nOd2L58uXx17/+NX73u9/FF7/4xaitrY1zzz13oPYBADlS1Y9C3njjjTj33HPjn//8Z4waNSpOPvnkWLlyZYwaNWqg9gEAOVJVWNx3330DtQMA2Av4rBAAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSqerTTVMqdL4bhZrurC5fUflTB2U9oU9K69/MekJFw363JesJFRVG7Jf1hD75r7PnZD2hosLm9VlPqKhm//2yntAn5Xffy3pCRWt/OiXrCRUd8ZO3s57QJ+837Z/1hF364IP3I/5a+TyvWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASKbqsHjzzTfjvPPOi5EjR8awYcPi6KOPjhdeeGEgtgEAOVNXzcnvvPNOTJ8+PU499dR49NFHY9SoUfHaa6/F/vvvP1D7AIAcqSosrrvuumhqaoq77rqr576JEycmHwUA5FNVPwp5+OGHY+rUqXHOOefE6NGj45hjjok77rhjl4/p6uqKjo6OXgcAsHeqKixef/31WLRoURx22GHx+OOPx0UXXRQXX3xx/PznP9/pY1pbW6OhoaHnaGpq2u3RAMDgVFVYlEqlOPbYY2PhwoVxzDHHxDe/+c248MILY/HixTt9TEtLS7S3t/ccbW1tuz0aABicqgqLcePGxZFHHtnrviOOOCI2bNiw08cUi8UYPnx4rwMA2DtVFRbTp0+PtWvX9rrv1VdfjQMPPDDpKAAgn6oKi0svvTRWrlwZCxcujHXr1sWSJUvi9ttvj+bm5oHaBwDkSFVhcfzxx8fSpUvj3nvvjUmTJsXVV18dN954Y8yZM2eg9gEAOVLV32MREXHWWWfFWWedNRBbAICc81khAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkqv7Y9FRKW7dFqbA9q8tXVNPdnfWEPvnumv+d9YSKFn7ta1lPqKhuc0fWE/qk8OqGrCdUVGgck/WEyjrfy3pB3+Tg69AR/+PPWU+obNzorBf0yY762qwn7NIHO/q2zysWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSqSosDjrooCgUCh86mpubB2ofAJAjddWcvGrVquju7u65/fLLL8fpp58e55xzTvJhAED+VBUWo0aN6nX72muvjUMOOSQ+97nPJR0FAORTVWHxH23fvj1+8YtfxIIFC6JQKOz0vK6urujq6uq53dHR0d9LAgCDXL/fvPnggw/Gli1b4hvf+MYuz2ttbY2Ghoaeo6mpqb+XBAAGuX6HxZ133hkzZ86MxsbGXZ7X0tIS7e3tPUdbW1t/LwkADHL9+lHI3/72t3jyySfjV7/6VcVzi8ViFIvF/lwGAMiZfr1icdddd8Xo0aNj1qxZqfcAADlWdViUSqW46667Yu7cuVFX1+/3fgIAe6Gqw+LJJ5+MDRs2xAUXXDAQewCAHKv6JYczzjgjyuXyQGwBAHLOZ4UAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGT2+Oee/+sDzD4o79jTl65KTSkfHwnfubU76wkVffDB+1lPqKy7K+sFfVIub896QkWFHDyXhdLgfx4j8vHPOxdy8O9kRMQHOwb318p/7av0QaSF8h7+qNI33ngjmpqa9uQlAYBE2traYvz48Tv99T0eFqVSKd56662or6+PQqGw279fR0dHNDU1RVtbWwwfPjzBwo8vz2U6nss0PI/peC7T+bg+l+VyObZu3RqNjY1RU7Pzd1Ls8df7a2pqdlk6/TV8+PCP1T/ggeS5TMdzmYbnMR3PZTofx+eyoaGh4jnevAkAJCMsAIBkch8WxWIxrrrqqigWi1lPyT3PZTqeyzQ8j+l4LtPxXO7aHn/zJgCw98r9KxYAwOAhLACAZIQFAJCMsAAAksl9WNx6661x0EEHxdChQ+OEE06I559/PutJudPa2hrHH3981NfXx+jRo+Pss8+OtWvXZj0r96699tooFAoxf/78rKfk0ptvvhnnnXdejBw5MoYNGxZHH310vPDCC1nPypXu7u644oorYuLEiTFs2LA45JBD4uqrr674WQ9ErFixImbPnh2NjY1RKBTiwQcf7PXr5XI5rrzyyhg3blwMGzYsZsyYEa+99lo2YweZXIfF/fffHwsWLIirrroqXnzxxZgyZUqceeaZsXnz5qyn5cry5cujubk5Vq5cGU888UTs2LEjzjjjjOjs7Mx6Wm6tWrUqbrvttpg8eXLWU3LpnXfeienTp8c+++wTjz76aLzyyivxk5/8JPbff/+sp+XKddddF4sWLYpbbrkl/vSnP8V1110X119/fdx8881ZTxv0Ojs7Y8qUKXHrrbd+5K9ff/31cdNNN8XixYvjueeei3333TfOPPPMeP/9wf1BYntEOcemTZtWbm5u7rnd3d1dbmxsLLe2tma4Kv82b95cjojy8uXLs56SS1u3bi0fdthh5SeeeKL8uc99rnzJJZdkPSl3LrvssvLJJ5+c9YzcmzVrVvmCCy7odd+XvvSl8pw5czJalE8RUV66dGnP7VKpVB47dmz5xz/+cc99W7ZsKReLxfK9996bwcLBJbevWGzfvj1Wr14dM2bM6LmvpqYmZsyYEc8++2yGy/Kvvb09IiJGjBiR8ZJ8am5ujlmzZvX6d5PqPPzwwzF16tQ455xzYvTo0XHMMcfEHXfckfWs3DnppJNi2bJl8eqrr0ZExB/+8Id45plnYubMmRkvy7f169fHxo0be/1/vKGhIU444QTffyKDDyFL5e23347u7u4YM2ZMr/vHjBkTf/7znzNalX+lUinmz58f06dPj0mTJmU9J3fuu+++ePHFF2PVqlVZT8m1119/PRYtWhQLFiyI733ve7Fq1aq4+OKLY8iQITF37tys5+XG5ZdfHh0dHXH44YdHbW1tdHd3xzXXXBNz5szJelqubdy4MSLiI7///OvXPs5yGxYMjObm5nj55ZfjmWeeyXpK7rS1tcUll1wSTzzxRAwdOjTrOblWKpVi6tSpsXDhwoiIOOaYY+Lll1+OxYsXC4sqPPDAA3HPPffEkiVL4qijjoo1a9bE/Pnzo7Gx0fPIgMntj0IOOOCAqK2tjU2bNvW6f9OmTTF27NiMVuXbvHnz4pFHHomnnnpqQD7afm+3evXq2Lx5cxx77LFRV1cXdXV1sXz58rjpppuirq4uuru7s56YG+PGjYsjjzyy131HHHFEbNiwIaNF+fTd7343Lr/88vjqV78aRx99dHzta1+LSy+9NFpbW7Oelmv/+h7j+89Hy21YDBkyJI477rhYtmxZz32lUimWLVsWJ554YobL8qdcLse8efNi6dKl8dvf/jYmTpyY9aRcOu200+Kll16KNWvW9BxTp06NOXPmxJo1a6K2tjbribkxffr0D/2R51dffTUOPPDAjBbl07vvvhs1Nb2/zNfW1kapVMpo0d5h4sSJMXbs2F7ffzo6OuK5557z/Sdy/qOQBQsWxNy5c2Pq1Kkxbdq0uPHGG6OzszPOP//8rKflSnNzcyxZsiQeeuihqK+v7/kZYUNDQwwbNizjdflRX1//ofel7LvvvjFy5EjvV6nSpZdeGieddFIsXLgwvvzlL8fzzz8ft99+e9x+++1ZT8uV2bNnxzXXXBMTJkyIo446Kn7/+9/HDTfcEBdccEHW0wa9bdu2xbp163pur1+/PtasWRMjRoyICRMmxPz58+NHP/pRHHbYYTFx4sS44oororGxMc4+++zsRg8WWf+xlN118803lydMmFAeMmRIedq0aeWVK1dmPSl3IuIjj7vuuivrabnnj5v2369//evypEmTysVisXz44YeXb7/99qwn5U5HR0f5kksuKU+YMKE8dOjQ8sEHH1z+/ve/X+7q6sp62qD31FNPfeTXxblz55bL5f//R06vuOKK8pgxY8rFYrF82mmnldeuXZvt6EHCx6YDAMnk9j0WAMDgIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACS+X9+X9UhkT1W+wAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "data = await pr.read_fluorescence(excitation_wavelength=485, emission_wavelength=528, focal_height=7.5)\n", + "plt.imshow(data)" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAhYAAAF2CAYAAAAyW9EUAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjkuMiwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy8hTgPZAAAACXBIWXMAAA9hAAAPYQGoP6dpAAAaEElEQVR4nO3df2yV9d3/8XdpxwFZ6ZTfjUVRnD9AmIo4xU2dqMEfmds95wxuDL/RzLtMkGzRblG3OC26zPkzKMbB7kwUlwx13lO/yBRjJlphTJkbynSj6hBdtIUaq7bn+8c39l5vhdNTPuXqhY9Hcv3Rw3W4XjkKfeacU05FsVgsBgBAAgOyHgAA7D6EBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJFO1qy/Y2dkZr732WlRXV0dFRcWuvjwA0AvFYjG2bt0atbW1MWDA9p+X2OVh8dprr0VdXd2uviwAkEBzc3Psvffe2/31XR4W1dXVEREx+b/+Myr3KOzqy/fYmxuHZT2hR/701V9kPaGkyb85L+sJJQ0f/6+sJ/TI+/89POsJJQ1b35b1hN3GS/+xR9YTdgv/+aUVWU/okYfqp2U9YYc+6GiPJ9b+rOv7+Pbs8rD48OWPyj0KUTmk/4bFgEGDsp7QI0Or+//bZPLwWPbn/xf/XefA/v9YVlV1ZD1ht5GHPzt5MOjTu/xbXa9UVeXjv3eptzH0/+9KAEBuCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkEyvwuKWW26JfffdNwYNGhRHHXVUPP3006l3AQA5VHZYLFu2LObPnx9XXHFFrF27NiZPnhynnHJKbNmypS/2AQA5UnZYXHfddXH++efH7Nmz45BDDolbb7019thjj/jFL37RF/sAgBwpKyzee++9WLNmTUyfPv1/foMBA2L69Onx5JNPfux92tvbo7W1tdsBAOyeygqLN998Mzo6OmLUqFHdbh81alRs3rz5Y+/T2NgYNTU1XUddXV3v1wIA/Vqf/1RIQ0NDtLS0dB3Nzc19fUkAICNV5Zw8fPjwqKysjNdff73b7a+//nqMHj36Y+9TKBSiUCj0fiEAkBtlPWMxcODAOOKII2LlypVdt3V2dsbKlSvj6KOPTj4OAMiXsp6xiIiYP39+zJo1K6ZMmRJTp06N66+/Ptra2mL27Nl9sQ8AyJGyw+Lss8+ON954Iy6//PLYvHlzfO5zn4uHHnroI2/oBAA+ecoOi4iIOXPmxJw5c1JvAQByzmeFAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkEyvPt00hff/e3h0DhyU1eVLO7iY9YIe2X/Zd7KesFuYuU9T1hN6ZEmcmvUEdqHxy97JekJJLZf3/40P/J/jsp7QM6ufzXrBjhXf79FpnrEAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkik7LB5//PE444wzora2NioqKuLee+/tg1kAQB6VHRZtbW0xefLkuOWWW/piDwCQY1Xl3mHGjBkxY8aMvtgCAORc2WFRrvb29mhvb+/6urW1ta8vCQBkpM/fvNnY2Bg1NTVdR11dXV9fEgDISJ+HRUNDQ7S0tHQdzc3NfX1JACAjff5SSKFQiEKh0NeXAQD6Af+OBQCQTNnPWGzbti02btzY9fXLL78c69ati7322ivGjh2bdBwAkC9lh8UzzzwTJ5xwQtfX8+fPj4iIWbNmxZIlS5INAwDyp+ywOP7446NYLPbFFgAg57zHAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGTK/nTTVFoOLMaAQf33U1I/85eKrCf0yJofLcx6QklH/OjCrCeUtOQvp2Y9oUc+deYbWU8o6c0YkfUEdqV7h2S9oLTVT2a9oEdO//NbWU/YoXe3fRCPTS19nmcsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkU1ZYNDY2xpFHHhnV1dUxcuTIOPPMM2PDhg19tQ0AyJmywmLVqlVRX18fq1evjhUrVsT7778fJ598crS1tfXVPgAgR6rKOfmhhx7q9vWSJUti5MiRsWbNmvjiF7+YdBgAkD9lhcX/1tLSEhERe+2113bPaW9vj/b29q6vW1tbd+aSAEA/1us3b3Z2dsa8efNi2rRpMXHixO2e19jYGDU1NV1HXV1dby8JAPRzvQ6L+vr6WL9+fdx99907PK+hoSFaWlq6jubm5t5eEgDo53r1UsicOXPigQceiMcffzz23nvvHZ5bKBSiUCj0ahwAkC9lhUWxWIzvfve7sXz58njsscdi3LhxfbULAMihssKivr4+li5dGvfdd19UV1fH5s2bIyKipqYmBg8e3CcDAYD8KOs9FgsXLoyWlpY4/vjjY8yYMV3HsmXL+mofAJAjZb8UAgCwPT4rBABIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGTK+nTTlGo2VETlwIqsLl/S2wfn45NcP7/ua1lPKOnbc3+X9YSSHpiwZ9YTemZR1gNK2/jz4VlPKGn8sneynrDbeHPSkKwnlLTx55/PekKPLLmh/35PjIjoeO/diFhV8jzPWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASKassFi4cGFMmjQphg4dGkOHDo2jjz46Hnzwwb7aBgDkTFlhsffee8eCBQtizZo18cwzz8SXvvSl+PKXvxx//vOf+2ofAJAjVeWcfMYZZ3T7+qqrroqFCxfG6tWrY8KECUmHAQD5U1ZY/LuOjo749a9/HW1tbXH00Udv97z29vZob2/v+rq1tbW3lwQA+rmy37z53HPPxac//ekoFArxne98J5YvXx6HHHLIds9vbGyMmpqarqOurm6nBgMA/VfZYXHggQfGunXr4qmnnooLL7wwZs2aFc8///x2z29oaIiWlpauo7m5eacGAwD9V9kvhQwcODDGjx8fERFHHHFENDU1xQ033BC33Xbbx55fKBSiUCjs3EoAIBd2+t+x6Ozs7PYeCgDgk6usZywaGhpixowZMXbs2Ni6dWssXbo0HnvssXj44Yf7ah8AkCNlhcWWLVviW9/6Vvzzn/+MmpqamDRpUjz88MNx0kkn9dU+ACBHygqLO+64o692AAC7AZ8VAgAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDJlfbppSsPWt0VVVUdWly9p+LNZL+iZ0+9oynpCSUtuODXrCaVdkPWAnhn+bFvWE0oav+ydrCeUtPHsPbKe0CMjDnwz6wklfXufVVlPKOm7e/4j6wk98vl1X8t6wg4NaGuPWNyD8/p+CgDwSSEsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAks1NhsWDBgqioqIh58+YlmgMA5Fmvw6KpqSluu+22mDRpUso9AECO9Sostm3bFjNnzozbb7899txzz9SbAICc6lVY1NfXx2mnnRbTp08veW57e3u0trZ2OwCA3VNVuXe4++67Y+3atdHU1NSj8xsbG+PHP/5x2cMAgPwp6xmL5ubmmDt3btx5550xaNCgHt2noaEhWlpauo7m5uZeDQUA+r+ynrFYs2ZNbNmyJQ4//PCu2zo6OuLxxx+Pm2++Odrb26OysrLbfQqFQhQKhTRrAYB+raywOPHEE+O5557rdtvs2bPjoIMOiksuueQjUQEAfLKUFRbV1dUxceLEbrcNGTIkhg0b9pHbAYBPHv/yJgCQTNk/FfK/PfbYYwlmAAC7A89YAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkMxOf7ppb730H3vEgEGDsrr8buP6/zsj6wmlHVzMekFJ45e9k/WEnln9bNYLSvv8pKwX7DZqTt2Y9YSSllxwatYTSnrg2basJ/RITdYDSvjgg549F+EZCwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyZQVFj/60Y+ioqKi23HQQQf11TYAIGeqyr3DhAkT4pFHHvmf36Cq7N8CANhNlV0FVVVVMXr06L7YAgDkXNnvsXjxxRejtrY29ttvv5g5c2Zs2rRph+e3t7dHa2trtwMA2D2VFRZHHXVULFmyJB566KFYuHBhvPzyy/GFL3whtm7dut37NDY2Rk1NTddRV1e306MBgP6prLCYMWNGnHXWWTFp0qQ45ZRT4ne/+128/fbbcc8992z3Pg0NDdHS0tJ1NDc37/RoAKB/2ql3Xn7mM5+Jz372s7Fx48btnlMoFKJQKOzMZQCAnNipf8di27Zt8be//S3GjBmTag8AkGNlhcX3vve9WLVqVfz973+PP/zhD/GVr3wlKisr45xzzumrfQBAjpT1Usgrr7wS55xzTvzrX/+KESNGxLHHHhurV6+OESNG9NU+ACBHygqLu+++u692AAC7AZ8VAgAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDJlfbppSvtd2hRVFZ/K6vIlbfz557OesNsYv+ydrCeUtvrZrBf0SMvvxmc9oaT37x2S9YSSxi9ry3pCj5z+57eynlDSnf94I+sJJW08eHjWE3pkxIFvZj1hhzra2iO+Vvo8z1gAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEim7LB49dVX49xzz41hw4bF4MGD49BDD41nnnmmL7YBADlTVc7Jb731VkybNi1OOOGEePDBB2PEiBHx4osvxp577tlX+wCAHCkrLK655pqoq6uLxYsXd902bty45KMAgHwq66WQ+++/P6ZMmRJnnXVWjBw5Mg477LC4/fbbd3if9vb2aG1t7XYAALunssLipZdeioULF8YBBxwQDz/8cFx44YVx0UUXxS9/+cvt3qexsTFqamq6jrq6up0eDQD0T2WFRWdnZxx++OFx9dVXx2GHHRYXXHBBnH/++XHrrbdu9z4NDQ3R0tLSdTQ3N+/0aACgfyorLMaMGROHHHJIt9sOPvjg2LRp03bvUygUYujQod0OAGD3VFZYTJs2LTZs2NDtthdeeCH22WefpKMAgHwqKywuvvjiWL16dVx99dWxcePGWLp0aSxatCjq6+v7ah8AkCNlhcWRRx4Zy5cvj7vuuismTpwYV155ZVx//fUxc+bMvtoHAORIWf+ORUTE6aefHqeffnpfbAEAcs5nhQAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkin7Y9NTeWnBkTFg0KCsLl/S+GXvZD2hRzaevUfWE0pquTwPj+X4rAf0SM2pG7OeUNKbF4zIekJpq5/NekGPLLnh1KwnlDT82basJ5T0xtlZL+iZmfs0ZT1hh97d9kGs7cF5nrEAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJBMWWGx7777RkVFxUeO+vr6vtoHAORIVTknNzU1RUdHR9fX69evj5NOOinOOuus5MMAgPwpKyxGjBjR7esFCxbE/vvvH8cdd1zSUQBAPpUVFv/uvffei1/96lcxf/78qKio2O557e3t0d7e3vV1a2trby8JAPRzvX7z5r333htvv/12fPvb397heY2NjVFTU9N11NXV9faSAEA/1+uwuOOOO2LGjBlRW1u7w/MaGhqipaWl62hubu7tJQGAfq5XL4X84x//iEceeSR+85vflDy3UChEoVDozWUAgJzp1TMWixcvjpEjR8Zpp52Weg8AkGNlh0VnZ2csXrw4Zs2aFVVVvX7vJwCwGyo7LB555JHYtGlTnHfeeX2xBwDIsbKfcjj55JOjWCz2xRYAIOd8VggAkIywAACSERYAQDLCAgBIRlgAAMkICwAgGWEBACQjLACAZIQFAJCMsAAAkhEWAEAywgIASGaXf+75hx9g1vnuu7v60mX54IP+ve9Dne/2/zbsaGvPesJu44Pi+1lPKKnjvf7/ZycPj2NETh7LHPxdmYe/JyMi3t32QdYTdujDfaU+iLSiuIs/qvSVV16Jurq6XXlJACCR5ubm2Hvvvbf767s8LDo7O+O1116L6urqqKio2Onfr7W1Nerq6qK5uTmGDh2aYOEnl8cyHY9lGh7HdDyW6XxSH8tisRhbt26N2traGDBg+88C7fKXQgYMGLDD0umtoUOHfqL+A/clj2U6Hss0PI7peCzT+SQ+ljU1NSXPyccLTwBALggLACCZ3IdFoVCIK664IgqFQtZTcs9jmY7HMg2PYzoey3Q8lju2y9+8CQDsvnL/jAUA0H8ICwAgGWEBACQjLACAZHIfFrfcckvsu+++MWjQoDjqqKPi6aefznpS7jQ2NsaRRx4Z1dXVMXLkyDjzzDNjw4YNWc/KvQULFkRFRUXMmzcv6ym59Oqrr8a5554bw4YNi8GDB8ehhx4azzzzTNazcqWjoyMuu+yyGDduXAwePDj233//uPLKK0t+1gMRjz/+eJxxxhlRW1sbFRUVce+993b79WKxGJdffnmMGTMmBg8eHNOnT48XX3wxm7H9TK7DYtmyZTF//vy44oorYu3atTF58uQ45ZRTYsuWLVlPy5VVq1ZFfX19rF69OlasWBHvv/9+nHzyydHW1pb1tNxqamqK2267LSZNmpT1lFx66623Ytq0afGpT30qHnzwwXj++efjZz/7Wey5555ZT8uVa665JhYuXBg333xz/OUvf4lrrrkmrr322rjpppuyntbvtbW1xeTJk+OWW2752F+/9tpr48Ybb4xbb701nnrqqRgyZEiccsop8W4//4DNXaKYY1OnTi3W19d3fd3R0VGsra0tNjY2Zrgq/7Zs2VKMiOKqVauynpJLW7duLR5wwAHFFStWFI877rji3Llzs56UO5dccknx2GOPzXpG7p122mnF8847r9ttX/3qV4szZ87MaFE+RURx+fLlXV93dnYWR48eXfzpT3/addvbb79dLBQKxbvuuiuDhf1Lbp+xeO+992LNmjUxffr0rtsGDBgQ06dPjyeffDLDZfnX0tISERF77bVXxkvyqb6+Pk477bRu/29Snvvvvz+mTJkSZ511VowcOTIOO+ywuP3227OelTvHHHNMrFy5Ml544YWIiPjTn/4UTzzxRMyYMSPjZfn28ssvx+bNm7v9Ga+pqYmjjjrK95/I4EPIUnnzzTejo6MjRo0a1e32UaNGxV//+teMVuVfZ2dnzJs3L6ZNmxYTJ07Mek7u3H333bF27dpoamrKekquvfTSS7Fw4cKYP39+/OAHP4impqa46KKLYuDAgTFr1qys5+XGpZdeGq2trXHQQQdFZWVldHR0xFVXXRUzZ87Melqubd68OSLiY7//fPhrn2S5DQv6Rn19faxfvz6eeOKJrKfkTnNzc8ydOzdWrFgRgwYNynpOrnV2dsaUKVPi6quvjoiIww47LNavXx+33nqrsCjDPffcE3feeWcsXbo0JkyYEOvWrYt58+ZFbW2tx5E+k9uXQoYPHx6VlZXx+uuvd7v99ddfj9GjR2e0Kt/mzJkTDzzwQDz66KN98tH2u7s1a9bEli1b4vDDD4+qqqqoqqqKVatWxY033hhVVVXR0dGR9cTcGDNmTBxyyCHdbjv44INj06ZNGS3Kp+9///tx6aWXxje+8Y049NBD45vf/GZcfPHF0djYmPW0XPvwe4zvPx8vt2ExcODAOOKII2LlypVdt3V2dsbKlSvj6KOPznBZ/hSLxZgzZ04sX748fv/738e4ceOynpRLJ554Yjz33HOxbt26rmPKlCkxc+bMWLduXVRWVmY9MTemTZv2kR95fuGFF2KfffbJaFE+vfPOOzFgQPe/5isrK6OzszOjRbuHcePGxejRo7t9/2ltbY2nnnrK95/I+Ush8+fPj1mzZsWUKVNi6tSpcf3110dbW1vMnj0762m5Ul9fH0uXLo377rsvqquru14jrKmpicGDB2e8Lj+qq6s/8r6UIUOGxLBhw7xfpUwXX3xxHHPMMXH11VfH17/+9Xj66adj0aJFsWjRoqyn5coZZ5wRV111VYwdOzYmTJgQf/zjH+O6666L8847L+tp/d62bdti48aNXV+//PLLsW7duthrr71i7NixMW/evPjJT34SBxxwQIwbNy4uu+yyqK2tjTPPPDO70f1F1j+WsrNuuumm4tixY4sDBw4sTp06tbh69eqsJ+VORHzssXjx4qyn5Z4fN+293/72t8WJEycWC4VC8aCDDiouWrQo60m509raWpw7d25x7NixxUGDBhX322+/4g9/+MNie3t71tP6vUcfffRj/16cNWtWsVj8/z9yetlllxVHjRpVLBQKxRNPPLG4YcOGbEf3Ez42HQBIJrfvsQAA+h9hAQAkIywAgGSEBQCQjLAAAJIRFgBAMsICAEhGWAAAyQgLACAZYQEAJCMsAIBkhAUAkMz/A2c0yL/w6ZmSAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "data = await pr.read_luminescence(focal_height=4.5)\n", + "plt.imshow(data)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Shaking" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "await pr.backend.shake(shake_type=Cytation5Backend.ShakeType.LINEAR)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "await pr.backend.stop_shaking()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.19" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/user_guide/fans.ipynb b/docs/user_guide/fans.ipynb new file mode 100644 index 0000000000..59d7c4ff20 --- /dev/null +++ b/docs/user_guide/fans.ipynb @@ -0,0 +1,79 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Fans" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.only_fans import Fan\n", + "from pylabrobot.only_fans import HamiltonHepaFan" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fan = Fan(backend=HamiltonHepaFan(), name=\"my fan\")\n", + "await fan.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Running for 60 seconds:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await fan.turn_on(intensity=100, duration=60)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Running until stop:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await fan.turn_on(intensity=100)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await fan.turn_off()" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/docs/user_guide/hamilton-star/hamilton-star.rst b/docs/user_guide/hamilton-star/hamilton-star.rst new file mode 100644 index 0000000000..3961815550 --- /dev/null +++ b/docs/user_guide/hamilton-star/hamilton-star.rst @@ -0,0 +1,10 @@ +Hamilton STAR +============= + +Tools for working with Hamilton-STAR specific functions. + +.. toctree:: + :maxdepth: 1 + + iswap-module + star_lld diff --git a/docs/user_guide/hamilton-star/iswap-module.md b/docs/user_guide/hamilton-star/iswap-module.md new file mode 100644 index 0000000000..1ee4b2c689 --- /dev/null +++ b/docs/user_guide/hamilton-star/iswap-module.md @@ -0,0 +1,13 @@ +# iSWAP Module + +The `R0` module allows fine grained control of the iSWAP gripper. + +## Rotations + +You can rotate the iSWAP to 12 predifined positions using `iswap_rotate` + +the positions and their corresponding integer specifications are shown visually here. + +![alt text](iswap_positions.png) + +For example to extend the iSWAP fully to the left, the position parameter to `iswap_rotate` would be `12` diff --git a/docs/user_guide/hamilton-star/iswap_positions.png b/docs/user_guide/hamilton-star/iswap_positions.png new file mode 100644 index 0000000000..a9d4cc8197 Binary files /dev/null and b/docs/user_guide/hamilton-star/iswap_positions.png differ diff --git a/docs/user_guide/hamilton-star/star_lld.md b/docs/user_guide/hamilton-star/star_lld.md new file mode 100644 index 0000000000..5fc2e831ea --- /dev/null +++ b/docs/user_guide/hamilton-star/star_lld.md @@ -0,0 +1,42 @@ +# Liquid level detection on Hamilton STAR(let) + +Liquid level detection (LLD) is a feature that allows the Hamilton STAR(let) to move the pipetting tip down slowly until a liquid is found using either a) the pressure sensor, or b) a change in capacitance, or c) both. This feature is useful if you want to aspirate or dispense at a distance relative to the liquid surface, but you don't know the exact height of the liquid in the container. + +To use LLD, you need to specify the LLD mode when calling the `aspirate` or `dispense` methods. Here is how you can use pressure or capacative LLD with the `aspirate` : + +```python +await lh.aspirate([tube], vols=[300], lld_mode=[STAR.LLDMode.GAMMA]) +``` + +The `lld_mode` parameter can be one of the following: + +- `STAR.LLDMode.OFF`: default, no LLD +- `STAR.LLDMode.GAMMA`: capacative LLD +- `STAR.LLDMode.PRESSURE`: pressure LLD +- `STAR.LLDMode.DUAL`: both capacative and pressure LLD +- `STAR.LLDMode.Z_TOUCH_OFF`: find the bottom of the container + +The `lld_mode` parameter is a list, so you can specify a different LLD mode for each channel. + +```{note} +The `lld_mode` parameter is only avilable when using the `STAR` backend. +``` + +## Catching errors + +All channelized pipetting operations raise a `ChannelizedError` exception when an error occurs, so that we can have specific error handling for each channel. + +When no liquid is found in the container, the channel will have a `TooLittleLiquidError` error. This is useful for detecting that your container is empty. + +You can catch the error like this: + +```python +from pylabrobot.liquid_handling.errors import ChannelizedError +from pylabrobot.resources.errors import TooLittleLiquidError +channel = 0 +try: + await lh.aspirate([tube], vols=[300], lld_mode=[STAR.LLDMode.GAMMA], use_channels=[channel]) +except ChannelizedError as e: + if isinstance(e.errors[channel], TooLittleLiquidError): + print("Too little liquid in tube") +``` diff --git a/docs/heating-shaking.ipynb b/docs/user_guide/heating-shaking.ipynb similarity index 100% rename from docs/heating-shaking.ipynb rename to docs/user_guide/heating-shaking.ipynb diff --git a/docs/img/installation/install-1.png b/docs/user_guide/img/installation/install-1.png similarity index 100% rename from docs/img/installation/install-1.png rename to docs/user_guide/img/installation/install-1.png diff --git a/docs/img/installation/install-2.png b/docs/user_guide/img/installation/install-2.png similarity index 100% rename from docs/img/installation/install-2.png rename to docs/user_guide/img/installation/install-2.png diff --git a/docs/img/installation/install-3.png b/docs/user_guide/img/installation/install-3.png similarity index 100% rename from docs/img/installation/install-3.png rename to docs/user_guide/img/installation/install-3.png diff --git a/docs/img/installation/install-4.png b/docs/user_guide/img/installation/install-4.png similarity index 100% rename from docs/img/installation/install-4.png rename to docs/user_guide/img/installation/install-4.png diff --git a/docs/img/installation/install-5.png b/docs/user_guide/img/installation/install-5.png similarity index 100% rename from docs/img/installation/install-5.png rename to docs/user_guide/img/installation/install-5.png diff --git a/docs/img/installation/uninstall-1.png b/docs/user_guide/img/installation/uninstall-1.png similarity index 100% rename from docs/img/installation/uninstall-1.png rename to docs/user_guide/img/installation/uninstall-1.png diff --git a/docs/img/installation/uninstall-2.png b/docs/user_guide/img/installation/uninstall-2.png similarity index 100% rename from docs/img/installation/uninstall-2.png rename to docs/user_guide/img/installation/uninstall-2.png diff --git a/docs/img/installation/uninstall-3.png b/docs/user_guide/img/installation/uninstall-3.png similarity index 100% rename from docs/img/installation/uninstall-3.png rename to docs/user_guide/img/installation/uninstall-3.png diff --git a/docs/img/installation/uninstall-4.png b/docs/user_guide/img/installation/uninstall-4.png similarity index 100% rename from docs/img/installation/uninstall-4.png rename to docs/user_guide/img/installation/uninstall-4.png diff --git a/docs/img/installation/uninstall-5.png b/docs/user_guide/img/installation/uninstall-5.png similarity index 100% rename from docs/img/installation/uninstall-5.png rename to docs/user_guide/img/installation/uninstall-5.png diff --git a/docs/img/installation/uninstall-6.png b/docs/user_guide/img/installation/uninstall-6.png similarity index 100% rename from docs/img/installation/uninstall-6.png rename to docs/user_guide/img/installation/uninstall-6.png diff --git a/docs/img/installation/uninstall-7.png b/docs/user_guide/img/installation/uninstall-7.png similarity index 100% rename from docs/img/installation/uninstall-7.png rename to docs/user_guide/img/installation/uninstall-7.png diff --git a/docs/img/visualizer/after_lh.png b/docs/user_guide/img/visualizer/after_lh.png similarity index 100% rename from docs/img/visualizer/after_lh.png rename to docs/user_guide/img/visualizer/after_lh.png diff --git a/docs/img/visualizer/assignment.png b/docs/user_guide/img/visualizer/assignment.png similarity index 100% rename from docs/img/visualizer/assignment.png rename to docs/user_guide/img/visualizer/assignment.png diff --git a/docs/img/visualizer/empty.png b/docs/user_guide/img/visualizer/empty.png similarity index 100% rename from docs/img/visualizer/empty.png rename to docs/user_guide/img/visualizer/empty.png diff --git a/docs/img/visualizer/resources.png b/docs/user_guide/img/visualizer/resources.png similarity index 100% rename from docs/img/visualizer/resources.png rename to docs/user_guide/img/visualizer/resources.png diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md new file mode 100644 index 0000000000..6a48f1bd0c --- /dev/null +++ b/docs/user_guide/index.md @@ -0,0 +1,76 @@ +# User guide + +```{toctree} +:maxdepth: 1 +:caption: Getting started + +installation +``` + +```{toctree} +:maxdepth: 1 +:caption: Liquid handling + +basic +using-the-visualizer +using-trackers +writing-robot-agnostic-methods +hamilton-star/hamilton-star +``` + +```{toctree} +:maxdepth: 1 +:caption: Plate reading + +plate_reading +cytation5 +``` + +```{toctree} +:maxdepth: 1 +:caption: Pumps + +pumps +``` + +```{toctree} +:maxdepth: 1 +:caption: Scales + +scales +``` + +```{toctree} +:maxdepth: 1 +:caption: Temperature controlling + +temperature +``` + +```{toctree} +:maxdepth: 1 +:caption: Tilting + +tilting +``` + +```{toctree} +:maxdepth: 1 +:caption: Heater shakers + +heating-shaking +``` + +```{toctree} +:maxdepth: 1 +:caption: Fans + +fans +``` + +```{toctree} +:maxdepth: 1 +:caption: Configuration + +configuration +``` diff --git a/docs/installation.md b/docs/user_guide/installation.md similarity index 84% rename from docs/installation.md rename to docs/user_guide/installation.md index 4cde05abf4..5072703e06 100644 --- a/docs/installation.md +++ b/docs/user_guide/installation.md @@ -27,7 +27,7 @@ cd pylabrobot pip install -e '.[dev]' ``` -See [CONTRIBUTING.md](https://github.com/PyLabRobot/pylabrobot/blob/main/CONTRIBUTING.md) for specific instructions on testing, documentation and development. +See [CONTRIBUTING.md](/contributor_guide/contributing) for specific instructions on testing, documentation and development. ### Using pip (often outdated NOT recommended) @@ -107,23 +107,23 @@ brew install libusb 3. Open Zadig and select "Options" -> "List All Devices". -![](./img/installation/install-1.png) +![](/user_guide/img/installation/install-1.png) 4. Select "ML Star" from the list if you're using a Hamilton STAR or STARlet. If you're using a Tecan robot, select "TECU". -![](./img/installation/install-2.png) +![](/user_guide/img/installation/install-2.png) 5. Select "libusbK" using the arrow buttons. -![](./img/installation/install-3.png) +![](/user_guide/img/installation/install-3.png) 6. Click "Replace Driver". -![](./img/installation/install-4.png) +![](/user_guide/img/installation/install-4.png) 7. Click "Close" to finish. -![](./img/installation/install-5.png) +![](/user_guide/img/installation/install-5.png) #### Uninstalling @@ -133,34 +133,34 @@ If you ever wish to switch back from firmware command to use `pyhamilton` or pla 1. This guide is only relevant if ML Star is listed under libusbK USB Devices in the Device Manager program. -![](./img/installation/uninstall-1.png) +![](/user_guide/img/installation/uninstall-1.png) 2. If that"s the case, double click "ML Star" (or similar) to open this dialog, then click "Driver". -![](./img/installation/uninstall-2.png) +![](/user_guide/img/installation/uninstall-2.png) 3. Click "Update Driver". -![](./img/installation/uninstall-3.png) +![](/user_guide/img/installation/uninstall-3.png) 4. Select "Browse my computer for driver software". -![](./img/installation/uninstall-4.png) +![](/user_guide/img/installation/uninstall-4.png) 5. Select "Let me pick from a list of device drivers on my computer". -![](./img/installation/uninstall-5.png) +![](/user_guide/img/installation/uninstall-5.png) 6. Select "Microlab STAR" and click "Next". -![](./img/installation/uninstall-6.png) +![](/user_guide/img/installation/uninstall-6.png) 7. Click "Close" to finish. -![](./img/installation/uninstall-7.png) +![](/user_guide/img/installation/uninstall-7.png) ### Troubleshooting If you get a `usb.core.NoBackendError: No backend available` error: [this](https://github.com/pyusb/pyusb/blob/master/docs/faq.rst#how-do-i-fix-no-backend-available-errors) may be helpful. -If you are still having trouble, please reach out on the [forum](https://forums.pylabrobot.org/c/pylabrobot-user-discussion/26). +If you are still having trouble, please reach out on [discuss.pylabrobot.org](https://discuss.pylabrobot.org). diff --git a/docs/plate_reading.md b/docs/user_guide/plate_reading.md similarity index 100% rename from docs/plate_reading.md rename to docs/user_guide/plate_reading.md diff --git a/docs/pumps.md b/docs/user_guide/pumps.md similarity index 100% rename from docs/pumps.md rename to docs/user_guide/pumps.md diff --git a/docs/scales.ipynb b/docs/user_guide/scales.ipynb similarity index 100% rename from docs/scales.ipynb rename to docs/user_guide/scales.ipynb diff --git a/docs/temperature.ipynb b/docs/user_guide/temperature.ipynb similarity index 94% rename from docs/temperature.ipynb rename to docs/user_guide/temperature.ipynb index 7849000130..f1f98412d2 100644 --- a/docs/temperature.ipynb +++ b/docs/user_guide/temperature.ipynb @@ -98,8 +98,8 @@ "metadata": {}, "outputs": [], "source": [ - "tc = OpentronsTemperatureModuleV2(name=\"tc\", opentrons_id=\"fc409cc91770129af8eb0a01724c56cb052b306a\")\n", - "await tc.setup()" + "t = OpentronsTemperatureModuleV2(name=\"t\", opentrons_id=\"fc409cc91770129af8eb0a01724c56cb052b306a\")\n", + "await t.setup()" ] }, { @@ -126,7 +126,7 @@ } ], "source": [ - "isinstance(tc, TemperatureController)" + "isinstance(t, TemperatureController)" ] }, { @@ -142,7 +142,7 @@ "metadata": {}, "outputs": [], "source": [ - "lh.deck.assign_child_at_slot(tc, slot=3)" + "lh.deck.assign_child_at_slot(t, slot=3)" ] }, { @@ -158,7 +158,7 @@ "metadata": {}, "outputs": [], "source": [ - "await tc.set_temperature(37)" + "await t.set_temperature(37)" ] }, { @@ -174,7 +174,7 @@ "metadata": {}, "outputs": [], "source": [ - "await tc.wait_for_temperature()" + "await t.wait_for_temperature()" ] }, { @@ -201,7 +201,7 @@ } ], "source": [ - "await tc.get_temperature()" + "await t.get_temperature()" ] }, { @@ -217,7 +217,7 @@ "metadata": {}, "outputs": [], "source": [ - "await tc.deactivate()" + "await t.deactivate()" ] }, { @@ -267,7 +267,7 @@ "metadata": {}, "outputs": [], "source": [ - "await lh.aspirate(tc.tube_rack[\"A1\"], vols=[20])" + "await lh.aspirate(t.tube_rack[\"A1\"], vols=[20])" ] }, { @@ -276,7 +276,7 @@ "metadata": {}, "outputs": [], "source": [ - "await lh.aspirate(tc.tube_rack[\"A6\"], vols=[20])" + "await lh.aspirate(t.tube_rack[\"A6\"], vols=[20])" ] }, { diff --git a/docs/user_guide/tilting.md b/docs/user_guide/tilting.md new file mode 100644 index 0000000000..dbcf84850a --- /dev/null +++ b/docs/user_guide/tilting.md @@ -0,0 +1,14 @@ +# Tilting + +Currently only the Hamilton tilt module is supported. + +```python +from pylabrobot.tilting.hamilton import HamiltonTiltModule + +tilter = HamiltonTiltModule(name="tilter", com_port="COM1") + +await lh.move_plate(my_plate, tilter) + +await tilter.set_angle(10) # absolute angle, clockwise, in degrees +await tilter.tilt(-1) # relative +``` diff --git a/docs/using-the-simulator.rst b/docs/user_guide/using-the-simulator.rst similarity index 100% rename from docs/using-the-simulator.rst rename to docs/user_guide/using-the-simulator.rst diff --git a/docs/using-the-visualizer.ipynb b/docs/user_guide/using-the-visualizer.ipynb similarity index 73% rename from docs/using-the-visualizer.ipynb rename to docs/user_guide/using-the-visualizer.ipynb index 97ed64c0cb..0cd65d9f2b 100644 --- a/docs/using-the-visualizer.ipynb +++ b/docs/user_guide/using-the-visualizer.ipynb @@ -9,7 +9,7 @@ "\n", "The Visualizer is a tool that allows you to visualize the a Resource (like LiquidHandler) including its state to easily see what is going on, for example when executing a protocol on a robot or when developing a new protocol.\n", "\n", - "When using a backend that does not require access to a physical robot, such as the {class}`~pylabrobot.liquid_handling.backends.chatterbox_backend.ChatterBoxBackend`, the Visualizer can be used to simulate a robot's behavior. Of course, you may also use the Visualizer when working with a real robot to see what is happening in the PLR resource and state trackers." + "When using a backend that does not require access to a physical robot, such as the {class}`~pylabrobot.liquid_handling.backends.chatterbox_backend.ChatterboxBackend`, the Visualizer can be used to simulate a robot's behavior. Of course, you may also use the Visualizer when working with a real robot to see what is happening in the PLR resource and state trackers." ] }, { @@ -20,85 +20,7 @@ "source": [ "## Setting up a connection with the robot\n", "\n", - "As described in the [basic liquid handling tutorial](basic), we will use the {class}`~pylabrobot.liquid_handling.liquid_handler.LiquidHandler` class to control the robot. This time, however, instead of using the Hamilton {class}`~pylabrobot.liquid_handling.backends.hamilton.STAR.STAR` backend, we are using the software-only {class}`~pylabrobot.liquid_handling.backends.chatterbox_backend.ChatterBoxBackend` backend. This means that liquid handling will work exactly the same, but commands are simply printed out to the console instead of being sent to a physical robot. We are still using the same deck." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "cb22680a", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Collecting websockets\n", - " Downloading websockets-12.0-cp310-cp310-win_amd64.whl.metadata (6.8 kB)\n", - "Downloading websockets-12.0-cp310-cp310-win_amd64.whl (124 kB)\n", - " ---------------------------------------- 0.0/125.0 kB ? eta -:--:--\n", - " ------------------- -------------------- 61.4/125.0 kB 1.7 MB/s eta 0:00:01\n", - " ---------------------------------------- 125.0/125.0 kB 1.9 MB/s eta 0:00:00\n", - "Installing collected packages: websockets\n", - "Successfully installed websockets-12.0\n" - ] - } - ], - "source": [ - "!pip install websockets" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "bb5d59f7", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Name: websockets\n", - "Version: 12.0\n", - "Summary: An implementation of the WebSocket Protocol (RFC 6455 & 7692)\n", - "Home-page: \n", - "Author: \n", - "Author-email: Aymeric Augustin \n", - "License: BSD-3-Clause\n", - "Location: c:\\users\\vikmol\\onedrive\\dokumenter\\github\\pylabrobot_dalsa\\venv\\lib\\site-packages\n", - "Requires: \n", - "Required-by: \n" - ] - } - ], - "source": [ - "!pip show websockets" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "287e535c", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Collecting websockets\n", - " Using cached websockets-12.0-cp310-cp310-win_amd64.whl.metadata (6.8 kB)\n", - "Using cached websockets-12.0-cp310-cp310-win_amd64.whl (124 kB)\n", - "Installing collected packages: websockets\n", - " Attempting uninstall: websockets\n", - " Found existing installation: websockets 12.0\n", - " Uninstalling websockets-12.0:\n", - " Successfully uninstalled websockets-12.0\n", - "Successfully installed websockets-12.0\n" - ] - } - ], - "source": [ - "!pip install --force-reinstall websockets" + "As described in the [basic liquid handling tutorial](basic), we will use the {class}`~pylabrobot.liquid_handling.liquid_handler.LiquidHandler` class to control the robot. This time, however, instead of using the Hamilton {class}`~pylabrobot.liquid_handling.backends.hamilton.STAR.STAR` backend, we are using the software-only {class}`~pylabrobot.liquid_handling.backends.chatterbox_backend.ChatterboxBackend` backend. This means that liquid handling will work exactly the same, but commands are simply printed out to the console instead of being sent to a physical robot. We are still using the same deck." ] }, { @@ -109,7 +31,7 @@ "outputs": [], "source": [ "from pylabrobot.liquid_handling import LiquidHandler\n", - "from pylabrobot.liquid_handling.backends import ChatterBoxBackend\n", + "from pylabrobot.liquid_handling.backends import ChatterboxBackend\n", "from pylabrobot.visualizer.visualizer import Visualizer" ] }, @@ -130,7 +52,7 @@ "metadata": {}, "outputs": [], "source": [ - "lh = LiquidHandler(backend=ChatterBoxBackend(), deck=STARLetDeck())" + "lh = LiquidHandler(backend=ChatterboxBackend(), deck=STARLetDeck())" ] }, { @@ -154,7 +76,8 @@ "text": [ "Setting up the robot.\n", "Resource deck was assigned to the robot.\n", - "Resource trash was assigned to the robot.\n" + "Resource trash was assigned to the robot.\n", + "Resource trash_core96 was assigned to the robot.\n" ] } ], @@ -219,7 +142,7 @@ "from pylabrobot.resources import (\n", " TIP_CAR_480_A00,\n", " PLT_CAR_L5AC_A00,\n", - " Cos_96_DW_1mL,\n", + " Cor_96_wellplate_360ul_Fb,\n", " HTF_L\n", ")" ] @@ -265,9 +188,9 @@ "outputs": [], "source": [ "plt_car = PLT_CAR_L5AC_A00(name='plate carrier')\n", - "plt_car[0] = plate_1 = Cos_96_DW_1mL(name='plate_01')\n", - "plt_car[1] = plate_2 = Cos_96_DW_1mL(name='plate_02')\n", - "plt_car[2] = plate_3 = Cos_96_DW_1mL(name='plate_03')" + "plt_car[0] = plate_1 = Cor_96_wellplate_360ul_Fb(name='plate_01')\n", + "plt_car[1] = plate_2 = Cor_96_wellplate_360ul_Fb(name='plate_02')\n", + "plt_car[2] = plate_3 = Cor_96_wellplate_360ul_Fb(name='plate_03')" ] }, { @@ -461,7 +384,7 @@ "source": [ "### Picking up tips\n", "\n", - "Note that since we are using the {class}`~pylabrobot.liquid_handling.backends.chatterbox_backend.ChatterBoxBackend`, we just print out \"Picking up tips\" instead of actually performing an operation. The visualizer will show the tips being picked up." + "Note that since we are using the {class}`~pylabrobot.liquid_handling.backends.chatterbox_backend.ChatterboxBackend`, we just print out \"Picking up tips\" instead of actually performing an operation. The visualizer will show the tips being picked up." ] }, { @@ -474,7 +397,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Picking up tips [Pickup(resource=TipSpot(name=tips_01_tipspot_0_0, location=(007.200, 068.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Pickup(resource=TipSpot(name=tips_01_tipspot_1_1, location=(016.200, 059.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Pickup(resource=TipSpot(name=tips_01_tipspot_2_2, location=(025.200, 050.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Pickup(resource=TipSpot(name=tips_01_tipspot_3_3, location=(034.200, 041.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK))].\n" + "Picking up tips [Pickup(resource=TipSpot(name=tips_01_tipspot_0_0, location=(007.200, 068.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Pickup(resource=TipSpot(name=tips_01_tipspot_1_1, location=(016.200, 059.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Pickup(resource=TipSpot(name=tips_01_tipspot_2_2, location=(025.200, 050.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Pickup(resource=TipSpot(name=tips_01_tipspot_3_3, location=(034.200, 041.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK))].\n" ] } ], @@ -492,7 +415,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Dropping tips [Drop(resource=TipSpot(name=tips_01_tipspot_0_0, location=(007.200, 068.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Drop(resource=TipSpot(name=tips_01_tipspot_1_1, location=(016.200, 059.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Drop(resource=TipSpot(name=tips_01_tipspot_2_2, location=(025.200, 050.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Drop(resource=TipSpot(name=tips_01_tipspot_3_3, location=(034.200, 041.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK))].\n" + "Dropping tips [Drop(resource=TipSpot(name=tips_01_tipspot_0_0, location=(007.200, 068.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Drop(resource=TipSpot(name=tips_01_tipspot_1_1, location=(016.200, 059.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Drop(resource=TipSpot(name=tips_01_tipspot_2_2, location=(025.200, 050.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK)), Drop(resource=TipSpot(name=tips_01_tipspot_3_3, location=(034.200, 041.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK))].\n" ] } ], @@ -518,7 +441,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Picking up tips [Pickup(resource=TipSpot(name=tips_01_tipspot_0_0, location=(007.200, 068.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK))].\n" + "Picking up tips [Pickup(resource=TipSpot(name=tips_01_tipspot_0_0, location=(007.200, 068.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK))].\n" ] } ], @@ -536,12 +459,12 @@ "name": "stdout", "output_type": "stream", "text": [ - "Aspirating [Aspiration(resource=Well(name=plate_01_well_1_0, location=(018.500, 070.000, 001.000), size_x=9.0, size_y=9.0, size_z=40.0, category=well), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK), volume=300, flow_rate=None, liquid_height=None, blow_out_air_volume=None, liquids=[(None, 300)])].\n" + "Aspirating [Aspiration(resource=Well(name=plate_01_well_1_0, location=(019.870, 070.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK), volume=200.0, flow_rate=None, liquid_height=None, blow_out_air_volume=None, liquids=[(None, 200.0)])].\n" ] } ], "source": [ - "await lh.aspirate(plate_1[\"A2\"], vols=[300])" + "await lh.aspirate(plate_1[\"A2\"], vols=[200])" ] }, { @@ -554,12 +477,12 @@ "name": "stdout", "output_type": "stream", "text": [ - "Dispensing [Dispense(resource=Well(name=plate_02_well_0_0, location=(009.500, 070.000, 001.000), size_x=9.0, size_y=9.0, size_z=40.0, category=well), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK), volume=300, flow_rate=None, liquid_height=None, blow_out_air_volume=None, liquids=[(None, 300)])].\n" + "Dispensing [Dispense(resource=Well(name=plate_02_well_0_0, location=(010.870, 070.770, 003.030), size_x=6.86, size_y=6.86, size_z=10.67, category=well), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK), volume=200.0, flow_rate=None, liquid_height=None, blow_out_air_volume=None, liquids=[(None, 200.0)])].\n" ] } ], "source": [ - "await lh.dispense(plate_2[\"A1\"], vols=[300])" + "await lh.dispense(plate_2[\"A1\"], vols=[200])" ] }, { @@ -572,7 +495,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Dropping tips [Drop(resource=TipSpot(name=tips_01_tipspot_0_0, location=(007.200, 068.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=None, tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK))].\n" + "Dropping tips [Drop(resource=TipSpot(name=tips_01_tipspot_0_0, location=(007.200, 068.300, -83.500), size_x=9.0, size_y=9.0, size_z=0, category=tip_spot), offset=Coordinate(x=0, y=0, z=0), tip=HamiltonTip(HIGH_VOLUME, has_filter=True, maximal_volume=1065, fitting_depth=8, total_tip_length=95.1, pickup_method=OUT_OF_RACK))].\n" ] } ], @@ -714,7 +637,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.11" + "version": "3.12.3" }, "toc": { "base_numbering": 1, diff --git a/docs/using-trackers.ipynb b/docs/user_guide/using-trackers.ipynb similarity index 99% rename from docs/using-trackers.ipynb rename to docs/user_guide/using-trackers.ipynb index fab3d7d7d5..e35d732702 100644 --- a/docs/using-trackers.ipynb +++ b/docs/user_guide/using-trackers.ipynb @@ -28,18 +28,18 @@ ], "source": [ "from pylabrobot.liquid_handling import LiquidHandler\n", - "from pylabrobot.liquid_handling.backends.chatterbox_backend import ChatterBoxBackend\n", + "from pylabrobot.liquid_handling.backends.chatterbox_backend import ChatterboxBackend\n", "from pylabrobot.resources import (\n", " TIP_CAR_480_A00,\n", " HTF_L,\n", " PLT_CAR_L5AC_A00,\n", - " Cos_96_EZWash,\n", + " Cor_96_wellplate_360ul_Fbate_360ul_Fb,\n", " set_tip_tracking,\n", " set_volume_tracking\n", ")\n", "from pylabrobot.resources.hamilton import STARLetDeck\n", "\n", - "lh = LiquidHandler(backend=ChatterBoxBackend(num_channels=8), deck=STARLetDeck())\n", + "lh = LiquidHandler(backend=ChatterboxBackend(num_channels=8), deck=STARLetDeck())\n", "await lh.setup()" ] }, @@ -605,7 +605,7 @@ "metadata": {}, "outputs": [], "source": [ - "plt_carrier[0] = plate = Cos_96_EZWash(name=\"plate\")" + "plt_carrier[0] = plate = Cor_96_wellplate_360ul_Fbate_360ul_Fb(name=\"plate\")" ] }, { diff --git a/docs/writing-robot-agnostic-methods.md b/docs/user_guide/writing-robot-agnostic-methods.md similarity index 100% rename from docs/writing-robot-agnostic-methods.md rename to docs/user_guide/writing-robot-agnostic-methods.md diff --git a/mypy.ini b/mypy.ini index a289d744a3..62d9dc9384 100644 --- a/mypy.ini +++ b/mypy.ini @@ -13,5 +13,8 @@ ignore_missing_imports = True [mypy-ot_api.*] ignore_missing_imports = True +[mypy-serial.*] +ignore_missing_imports = True + [mypy-pylibftdi.*] ignore_missing_imports = True diff --git a/pylabrobot/__init__.py b/pylabrobot/__init__.py index ddbcbac9be..af8946ff14 100644 --- a/pylabrobot/__init__.py +++ b/pylabrobot/__init__.py @@ -1,15 +1,77 @@ import datetime import logging +from pathlib import Path +import sys +from typing import Union +import warnings from pylabrobot.__version__ import __version__ -# Create a logger -logger = logging.getLogger("pylabrobot") -logger.setLevel(logging.DEBUG) +from pylabrobot.config import load_config, Config -# Add a file handler -now = datetime.datetime.now().strftime("%Y%m%d") -fh = logging.FileHandler(f"pylabrobot-{now}.log") -fh.setLevel(logging.DEBUG) -fh.setFormatter(logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) -logger.addHandler(fh) +CONFIG_FILE_NAME = "pylabrobot" + +CONFIG = load_config(CONFIG_FILE_NAME, create_default=True) +"""The loaded configuration for pylabrobot.""" + + +def project_root() -> Path: + """ + Get the root directory of the project. + From https://stackoverflow.com/a/53465812 + Returns: + The root directory of the project. + """ + return Path(__file__).parent.parent + +def setup_logger(log_dir: Union[Path, str], level: int): + """ + Set up the logger for pylabrobot. If the log_dir does not exist, it will be created. + + Args: + log_dir: The directory to store the log files. + level: The logging level. + + """ + # Create a logger + if isinstance(log_dir, str): + log_dir = Path(log_dir) + if not log_dir.exists(): + log_dir.mkdir(parents=True) + logger = logging.getLogger("pylabrobot") + logger.setLevel(level) + + now = datetime.datetime.now().strftime("%Y%m%d") + # remove file handler if it exists + if len(logger.handlers) > 0: + logger.handlers.clear() + # delete empty log file if it has been created + log_file = Path(f"pylabrobot-{now}.log") + if log_file.exists() and log_file.stat().st_size == 0: + log_file.unlink() + + # Add a file handler + fh = logging.FileHandler(log_dir / f"pylabrobot-{now}.log") + fh.setLevel(level) + fh.setFormatter( + logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")) + logger.addHandler(fh) + + +def configure(cfg: Config): + """ + Configure pylabrobot. + + Args: + cfg: The Config object. + """ + setup_logger(cfg.logging.log_dir, cfg.logging.level) + + +configure(CONFIG) + + +# deprecation warning for 3.8 +if sys.version_info < (3, 9): + warnings.warn("Support for Python 3.8 is deprecated and will be removed in Dec 2024. " + "Please upgrade to Python 3.9 or later.") diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/centrifuge/__init__.py new file mode 100644 index 0000000000..d331ab27cf --- /dev/null +++ b/pylabrobot/centrifuge/__init__.py @@ -0,0 +1,2 @@ +from .centrifuge import Centrifuge +from .vspin import VSpin diff --git a/pylabrobot/centrifuge/backend.py b/pylabrobot/centrifuge/backend.py new file mode 100644 index 0000000000..7a70f83476 --- /dev/null +++ b/pylabrobot/centrifuge/backend.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod + +from pylabrobot.machines.backends import MachineBackend + +class CentrifugeBackend(MachineBackend, metaclass=ABCMeta): + """ An abstract class for a centrifuge""" + @abstractmethod + async def setup(self) -> None: + """ Set up the centrifuge. This should be called before any other methods. """ + + @abstractmethod + async def stop(self) -> None: + """Close all connections to the centrifuge. """ + + @abstractmethod + async def open_door(self) -> None: + pass + + @abstractmethod + async def close_door(self) -> None: + pass + + @abstractmethod + async def lock_door(self) -> None: + pass + + @abstractmethod + async def unlock_door(self) -> None: + pass + + @abstractmethod + async def go_to_bucket1(self) -> None: + pass + + @abstractmethod + async def go_to_bucket2(self) -> None: + pass + + @abstractmethod + async def rotate_distance(self, distance) -> None: + pass + + @abstractmethod + async def lock_bucket(self) -> None: + pass + + @abstractmethod + async def unlock_bucket(self) -> None: + pass + + @abstractmethod + async def start_spin_cycle(self, g: float, duration: float, acceleration: float) -> None: + pass diff --git a/pylabrobot/centrifuge/centrifuge.py b/pylabrobot/centrifuge/centrifuge.py new file mode 100644 index 0000000000..248941f643 --- /dev/null +++ b/pylabrobot/centrifuge/centrifuge.py @@ -0,0 +1,59 @@ +from typing import Optional + +from pylabrobot.machines.machine import Machine +from pylabrobot.centrifuge.backend import CentrifugeBackend + +class Centrifuge(Machine): + """ The front end for centrifuges. + Centrifuges are devices that can spin samples at high speeds.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: CentrifugeBackend, + category: Optional[str] = None, + model: Optional[str] = None, + ) -> None: + super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, backend=backend, + category=category, model=model) + self.backend: CentrifugeBackend = backend # fix type + + async def stop(self) -> None: + await self.backend.stop() + + async def open_door(self) -> None: + await self.backend.open_door() + + async def close_door(self) -> None: + await self.backend.close_door() + + async def lock_door(self) -> None: + await self.backend.lock_door() + + async def unlock_door(self) -> None: + await self.backend.unlock_door() + + async def unlock_bucket(self) -> None: + await self.backend.unlock_bucket() + + async def lock_bucket(self) -> None: + await self.backend.lock_bucket() + + async def go_to_bucket1(self) -> None: + await self.backend.go_to_bucket1() + + async def go_to_bucket2(self) -> None: + await self.backend.go_to_bucket2() + + async def rotate_distance(self, distance) -> None: + await self.backend.rotate_distance(distance = distance) + + async def start_spin_cycle(self, g: float, duration: float, acceleration: float) -> None: + await self.backend.start_spin_cycle( + g=g, + duration=duration, + acceleration=acceleration, + ) diff --git a/pylabrobot/centrifuge/vspin.py b/pylabrobot/centrifuge/vspin.py new file mode 100644 index 0000000000..a5b641a1fb --- /dev/null +++ b/pylabrobot/centrifuge/vspin.py @@ -0,0 +1,389 @@ +import asyncio +import logging +import time +from typing import Optional, Union + +from .backend import CentrifugeBackend + +try: + from pylibftdi import Device + USE_FTDI = True +except ImportError: + USE_FTDI = False + + +logger = logging.getLogger("pylabrobot.centrifuge.vspin") + + +class VSpin(CentrifugeBackend): + """ Backend for the Agilent Centrifuge. + Note that this is not a complete implementation. """ + + def __init__(self, bucket_1_position: int, device_id: Optional[str] = None): + """ + Args: + device_id: The libftdi id for the centrifuge. Find using + `python3 -m pylibftdi.examples.list_devices` + bucket_1_position: The position of bucket 1 in the centrifuge. At first run, intialize with + an arbitrary value, move to the bucket, and call get_position() to get the position. Then + use this value for future runs. + """ + if not USE_FTDI: + raise RuntimeError("pylibftdi is not installed.") + self.dev = Device(lazy_open=True, device_id=device_id) + self.bucket_1_position = bucket_1_position + self.homing_position = 0 + + async def setup(self): + self.dev = Device() + self.dev.open() + logger.debug("open") + # TODO: add functionality where if robot has been intialized before nothing needs to happen + for _ in range(3): + await self.configure_and_initialize() + await self.send(b"\xaa\x00\x21\x01\xff\x21") + await self.send(b"\xaa\x00\x21\x01\xff\x21") + await self.send(b"\xaa\x01\x13\x20\x34") + await self.send(b"\xaa\x00\x21\x02\xff\x22") + await self.send(b"\xaa\x02\x13\x20\x35") + await self.send(b"\xaa\x00\x21\x03\xff\x23") + await self.send(b"\xaa\xff\x1a\x14\x2d") + + self.dev.baudrate = 57600 + self.dev.ftdi_fn.ftdi_setrts(1) + self.dev.ftdi_fn.ftdi_setdtr(1) + + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x01\x12\x1f\x32") + for _ in range(8): + await self.send(b"\xaa\x02\x20\xff\x0f\x30") + await self.send(b"\xaa\x02\x20\xdf\x0f\x10") + await self.send(b"\xaa\x02\x20\xdf\x0e\x0f") + await self.send(b"\xaa\x02\x20\xdf\x0c\x0d") + await self.send(b"\xaa\x02\x20\xdf\x08\x09") + for _ in range(4): + await self.send(b"\xaa\x02\x26\x00\x00\x28") + await self.send(b"\xaa\x02\x12\x03\x17") + for _ in range(5): + await self.send(b"\xaa\x02\x26\x20\x00\x48") + await self.send(b"\xaa\x02\x0e\x10") + await self.send(b"\xaa\x02\x26\x00\x00\x28") + await self.send(b"\xaa\x02\x0e\x10") + await self.send(b"\xaa\x02\x0e\x10") + await self.lock_door() + + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x02\x0e\x10") + + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x02\x0e\x10") + + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x02\x0e\x10") + + await self.send(b"\xaa\x02\x0e\x10") + await self.send(b"\xaa\x01\x0e\x0f") + + await self.send(b"\xaa\x02\x0e\x10") + await self.send(b"\xaa\x02\x26\x00\x00\x28") + await self.send(b"\xaa\x02\x0e\x10") + + await self.send(b"\xaa\x02\x0e\x10") + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x02\x0e\x10") + + await self.send(b"\xaa\x01\x17\x02\x1a") + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x01\xe6\xc8\x00\xb0\x04\x96\x00\x0f\x00\x4b\x00\xa0\x0f\x05\x00\x07") + await self.send(b"\xaa\x01\x17\x04\x1c") + await self.send(b"\xaa\x01\x17\x01\x19") + + await self.send(b"\xaa\x01\x0b\x0c") + await self.send(b"\xaa\x01\x00\x01") + await self.send(b"\xaa\x01\xe6\x05\x00\x64\x00\x00\x00\x00\x00\x32\x00\xe8\x03\x01\x00\x6e") + await self.send(b"\xaa\x01\x94\xb6\x12\x83\x00\x00\x12\x01\x00\x00\xf3") + await self.send(b"\xaa\x01\x19\x28\x42") + await self.send(b"\xaa\x01\x0e\x0f") + + resp = 0x89 + while resp == 0x89: + await self.send(b"\xaa\x02\x0e\x10") + stat = await self.send(b"\xaa\x01\x0e\x0f") + resp = stat[0] + + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x01\x0e\x0f") + + await self.send(b"\xaa\x01\x17\x02\x1a") + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x01\xe6\xc8\x00\xb0\x04\x96\x00\x0f\x00\x4b\x00\xa0\x0f\x05\x00\x07") + await self.send(b"\xaa\x01\x17\x04\x1c") + await self.send(b"\xaa\x01\x17\x01\x19") + + await self.send(b"\xaa\x01\x0b\x0c") + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x01\xe6\xc8\x00\xb0\x04\x96\x00\x0f\x00\x4b\x00\xa0\x0f\x05\x00\x07") + new_position = (self.homing_position + 8000).to_bytes(4, byteorder="little") + await self.send(b"\xaa\x01\xd4\x97" + new_position + b"\xc3\xf5\x28\x00\xd7\x1a\x00\x00\x49") + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x01\x0e\x0f") + + resp = 0x08 + while resp != 0x09: + stat = await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x01\x0e\x0f") + resp = stat[0] + + await self.send(b"\xaa\x01\x0e\x0f") + await self.send(b"\xaa\x01\x0e\x0f") + + await self.send(b"\xaa\x01\x17\x02\x1a") + + await self.send(b"\xaa\x02\x0e\x10") + await self.lock_door() + + await self.send(b"\xaa\x01\x0e\x0f") + + async def stop(self): + await self.send(b"\xaa\x02\x0e\x10") + await self.configure_and_initialize() + if self.dev: + self.dev.close() + + async def get_status(self): + """Returns 14 bytes + + Example: + 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 + + First byte (index 0): + - 11 = idle + - 13 = unknown + - 08 = spinning + - 09 = also spinning but different + - 19 = unknown + 2nd to 5th byte (index 1-4) = Position + 10th to 13th byte (index 9-12) = Homing Position + Last byte (index 13) = checksum + + """ + resp = await self.send(b"\xaa\x01\x0e\x0f") + if len(resp) == 0: + raise IOError("Empty status from centrifuge") + return resp + + async def get_position(self): + resp = await self.get_status() + return int.from_bytes(resp[1:5], byteorder="little") + +# Centrifuge communication: read_resp, send, send_payloads + + async def read_resp(self, timeout=20) -> bytes: + """Read a response from the centrifuge. If the timeout is reached, return the data that has + been read so far.""" + if not self.dev: + raise RuntimeError("Device not initialized") + + data = b"" + end_byte_found = False + start_time = time.time() + + while True: + chunk = self.dev.read(25) + if chunk: + data += chunk + end_byte_found = data[-1] == 0x0d + if len(chunk) < 25 and end_byte_found: + break + else: + if end_byte_found or time.time() - start_time > timeout: + logger.warning("Timed out reading response") + break + await asyncio.sleep(0.0001) + + logger.debug("Read %s", data.hex()) + return data + + async def send(self, cmd: Union[bytearray, bytes], read_timeout=0.2) -> bytes: + logger.debug("Sending %s", cmd.hex()) + written = self.dev.write(cmd.decode("latin-1")) + logger.debug("Wrote %s bytes", written) + + if written != len(cmd): + raise RuntimeError("Failed to write all bytes") + return await self.read_resp(timeout=read_timeout) + + async def send_payloads(self, payloads) -> None: + """Send a list of commands to the centrifuge.""" + for tx in payloads: + if isinstance(tx, str): + byte_literal = bytes.fromhex(tx) + await self.send(byte_literal) + else: + await self.send(tx) + + async def configure_and_initialize(self): + await self.set_configuration_data() + await self.initialize() + + async def set_configuration_data(self): + """Set the device configuration data.""" + self.dev.ftdi_fn.ftdi_set_latency_timer(16) + self.dev.ftdi_fn.ftdi_set_line_property(8, 1, 0) + self.dev.ftdi_fn.ftdi_setflowctrl(0) + self.dev.baudrate = 19200 + + async def initialize(self): + if self.dev: + self.dev.write(b"\x00" * 20) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0e, 0x0e + (i & 0xFF)]) + b"\x00" * 8 + self.dev.write(packet) + await self.send(b"\xaa\xff\x0f\x0e") + +# Centrifuge operations + + async def open_door(self): + await self.send(b"\xaa\x02\x26\x00\x07\x2f") + await self.send(b"\xaa\x02\x0e\x10") + # we can't tell when the door is fully open, so we just wait a bit + await asyncio.sleep(4) + + async def close_door(self): + await self.send(b"\xaa\x02\x26\x00\x05\x2d") + await self.send(b"\xaa\x02\x0e\x10") + # we can't tell when the door is fully closed, so we just wait a bit + await asyncio.sleep(2) + + async def lock_door(self): + await self.send(b"\xaa\x02\x26\x00\x01\x29") + await self.send(b"\xaa\x02\x0e\x10") + + async def unlock_door(self): + await self.send(b"\xaa\x02\x26\x00\x05\x2d") + await self.send(b"\xaa\x02\x0e\x10") + + async def lock_bucket(self): + await self.send(b"\xaa\x02\x26\x00\x07\x2f") + await self.send(b"\xaa\x02\x0e\x10") + + async def unlock_bucket(self): + await self.send(b"\xaa\x02\x26\x00\x06\x2e") + await self.send(b"\xaa\x02\x0e\x10") + + async def go_to_bucket1(self): + await self.go_to_position(self.bucket_1_position) + + async def go_to_bucket2(self): + half_rotation = 4000 + await self.go_to_position(self.bucket_1_position + half_rotation) + + async def rotate_distance(self, distance): + current_position = await self.get_position() + await self.go_to_position(current_position + distance) + + async def go_to_position(self, position: int): + await self.close_door() + await self.lock_door() + + position_bytes = position.to_bytes(4, byteorder="little") + byte_string = b"\xaa\x01\xd4\x97" + position_bytes + b"\xc3\xf5\x28\x00\xd7\x1a\x00\x00" + sum_byte = (sum(byte_string)-0xaa)&0xff + byte_string += sum_byte.to_bytes(1, byteorder="little") + move_bucket = [ + "aa 02 26 00 00 28", + "aa 02 0e 10", + "aa 01 17 02 1a", + "aa 01 0e 0f", + "aa 01 e6 c8 00 b0 04 96 00 0f 00 4b 00 a0 0f 05 00 07", + "aa 01 17 04 1c", + "aa 01 17 01 19", + "aa 01 0b 0c", + "aa 01 e6 c8 00 b0 04 96 00 0f 00 4b 00 a0 0f 05 00 07", + byte_string + ] + await self.send_payloads(move_bucket) + + await asyncio.sleep(2) + + await self.send(b"\xaa\x01\x17\x02\x1a") + await self.open_door() + + async def start_spin_cycle( + self, + g: float = 500, + duration: float = 60, + acceleration: float = 80, + ) -> None: + """Start a spin cycle. spin spin spin spin + + Args: + g: relative centrifugal force, also known as g-force + duration: How much time spent actually spinning at the desired g in seconds + acceleration: 1-100% of total acceleration + + Examples: + Spin with 1000 g-force (close to 3000rpm) for 5 minutes at 100% acceleration + + >>> cf.start_spin_cycle(g = 1000, duration = 300, acceleration = 100) + """ + + if acceleration < 1 or acceleration > 100: + raise ValueError("Acceleration must be within 1-100.") + if g < 1 or g > 1000: + raise ValueError("G-force must be within 1-1000") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") + + await self.close_door() + await self.lock_door() + + rpm = int((g/(1.118*(10**(-4))))**0.5) + base = int(107007 - 328*rpm + 1.13*(rpm**2)) + rpm_b = (int(4481*rpm + 10852)).to_bytes(4, byteorder="little") + acc = (int(915*acceleration/100)).to_bytes(2, byteorder="little") + maxp = min((await self.get_position() + base + 4000*rpm//30*duration), 4294967294) + position = maxp.to_bytes(4, byteorder="little") + + byte_string = b"\xaa\x01\xd4\x97" + position + rpm_b + acc+b"\x00\x00" + last_byte = (sum(byte_string)-0xaa)&0xff + byte_string += last_byte.to_bytes(1, byteorder="little") + + payloads = [ + "aa 02 26 00 00 28", + "aa 02 0e 10", + "aa 01 17 02 1a", + "aa 01 0e 0f", + "aa 01 e6 c8 00 b0 04 96 00 0f 00 4b 00 a0 0f 05 00 07", + "aa 01 17 04 1c", + "aa 01 17 01 19", + "aa 01 0b 0c", + "aa 01 0e 0f", + "aa 01 e6 05 00 64 00 00 00 00 00 fd 00 80 3e 01 00 0c", + byte_string, + ] + await self.send_payloads(payloads) + + status_resp = await self.get_status() + status = status_resp[0] + while status == 0x08: + await asyncio.sleep(1) + status_resp = await self.get_status() + status = status_resp[0] + + await asyncio.sleep(2) + + # reset position back to 0ish + # this part is needed because otherwise calling go_to_position will not work after + payloads = [ + "aa 01 e6 c8 00 b0 04 96 00 0f 00 4b 00 a0 0f 05 00 07", + "aa 01 17 04 1c", + "aa 01 17 01 19", + "aa 01 0b 0c", + "aa 01 00 01", + "aa 01 e6 05 00 64 00 00 00 00 00 32 00 e8 03 01 00 6e", + "aa 01 94 b6 12 83 00 00 12 01 00 00 f3", + "aa 01 19 28 42" + ] + + await self.send_payloads(payloads) diff --git a/pylabrobot/gui/README.md b/pylabrobot/gui/README.md index 8b8626bb50..35274ceb75 100644 --- a/pylabrobot/gui/README.md +++ b/pylabrobot/gui/README.md @@ -1,3 +1,3 @@ # PyLabRobot Graphical Labware Editor -I am not sure if this still works. Please get in touch on the [forum](https://forums.pylabrobot.org) if you are interested in this project. +I am not sure if this still works. Please get in touch on the [forum](https://discuss.pylabrobot.org) if you are interested in this project. diff --git a/pylabrobot/gui/gui.py b/pylabrobot/gui/gui.py index 4021a8c3d4..47cbdc601e 100644 --- a/pylabrobot/gui/gui.py +++ b/pylabrobot/gui/gui.py @@ -10,7 +10,7 @@ print("!" * 80) print("I am not sure if the GUI still works. If you are interested in using this, please get in " - "touch on forums.pylabrobot.org") + "touch on discuss.pylabrobot.org") print("!" * 80) app = Flask(__name__, template_folder=".", static_folder=".") @@ -64,9 +64,9 @@ def list_resources(): "Cos_96_DW_500ul", "Cos_96_DW_500ul_L", "Cos_96_DW_500ul_P", - "Cos_96_EZWash", - "Cos_96_EZWash_L", - "Cos_96_EZWash_P", + "Cor_96_wellplate_360ul_Fb", + "Cor_96_wellplate_360ul_Fb_L", + "Cor_96_wellplate_360ul_Fb_P", "Cos_96_FL", "Cos_96_Filter", "Cos_96_Filter_L", diff --git a/pylabrobot/heating_shaking/chatterbox.py b/pylabrobot/heating_shaking/chatterbox.py new file mode 100644 index 0000000000..0dd5a240fa --- /dev/null +++ b/pylabrobot/heating_shaking/chatterbox.py @@ -0,0 +1,7 @@ +from pylabrobot.shaking.chatterbox import ShakerChatterboxBackend +from pylabrobot.temperature_controlling.chatterbox import TemperatureControllerChatterboxBackend + + +class HeaterShakerChatterboxBackend( + ShakerChatterboxBackend, TemperatureControllerChatterboxBackend): + pass diff --git a/pylabrobot/heating_shaking/inheco.py b/pylabrobot/heating_shaking/inheco.py index a8c6dc73ef..fd8fe042a9 100644 --- a/pylabrobot/heating_shaking/inheco.py +++ b/pylabrobot/heating_shaking/inheco.py @@ -31,6 +31,14 @@ async def stop(self): await self.stop_temperature_control() self.device.close() + def serialize(self) -> dict: + return { + **super().serialize(), + "vid": self.vid, + "pid": self.pid, + "serial_number": self.serial_number + } + @typing.no_type_check def _generate_packets(self, msg): """ Generate packets for the given message. diff --git a/pylabrobot/liquid_handling/backends/USBBackend.py b/pylabrobot/liquid_handling/backends/USBBackend.py deleted file mode 100644 index 0a139522f4..0000000000 --- a/pylabrobot/liquid_handling/backends/USBBackend.py +++ /dev/null @@ -1,244 +0,0 @@ -# pylint: disable=invalid-name - -from abc import ABCMeta, abstractmethod -import logging -import time -from typing import List, Optional, TYPE_CHECKING -import libusb_package - -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend - -try: - import usb.core - import usb.util - USE_USB = True -except ImportError: - USE_USB = False - - -if TYPE_CHECKING: - import usb.core - - -logger = logging.getLogger("pylabrobot") - - -class USBBackend(LiquidHandlerBackend, metaclass=ABCMeta): - """ An abstract class for liquid handler backends that talk over a USB cable. Provides read/write - functionality, including timeout handling. """ - - @abstractmethod - def __init__( - self, - id_vendor: int, - id_product: int, - address: Optional[int] = None, - serial_number: Optional[str] = None, - packet_read_timeout: int = 3, - read_timeout: int = 30, - write_timeout: int = 30 - ): - """ Initialize a USBBackend. - - Args: - id_vendor: The USB vendor ID of the machine. - id_product: The USB product ID of the machine. - address: The USB address of the machine. If `None`, use the first device found. This is useful - for machines that have no unique serial number, such as the Hamilton STAR. - serial_number: The serial number of the machine. If `None`, use the first device found. - packet_read_timeout: The timeout for reading packets from the machine in seconds. - read_timeout: The timeout for reading from the machine in seconds. - write_timeout: The timeout for writing to the machine in seconds. - """ - - super().__init__() - - assert packet_read_timeout < read_timeout, \ - "packet_read_timeout must be smaller than read_timeout." - - self.id_vendor = id_vendor - self.id_product = id_product - self.address = address - self.serial_number = serial_number - - self.packet_read_timeout = packet_read_timeout - self.read_timeout = read_timeout - self.write_timeout = write_timeout - - self.dev: Optional["usb.core.Device"] = None # TODO: make this a property - self.read_endpoint: Optional[usb.core.Endpoint] = None - self.write_endpoint: Optional[usb.core.Endpoint] = None - - def write(self, data: str, timeout: Optional[int] = None): - """ Write data to the device. - - Args: - data: The data to write. - timeout: The timeout for writing to the device in seconds. If `None`, use the default timeout - (specified by the `write_timeout` attribute). - """ - - assert self.dev is not None and self.read_endpoint is not None, "Device not connected." - - if timeout is None: - timeout = self.write_timeout - - # write command to endpoint - self.dev.write(self.write_endpoint, data, timeout=timeout) - logger.info("Sent command: %s", data) - - def _read_packet(self) -> Optional[bytearray]: - """ Read a packet from the machine. - - Returns: - A string containing the decoded packet, or None if no packet was received. - """ - - assert self.dev is not None and self.read_endpoint is not None, "Device not connected." - - try: - res = self.dev.read( - self.read_endpoint, - self.read_endpoint.wMaxPacketSize, - timeout=int(self.packet_read_timeout * 1000) # timeout in ms - ) - - if res is not None: - return bytearray(res) # convert res into text - return None - except usb.core.USBError: - # No data available (yet), this will give a timeout error. Don't reraise. - return None - - def read(self, timeout: Optional[int] = None) -> bytearray: - """ Read a response from the device. - - Args: - timeout: The timeout for reading from the device in seconds. If `None`, use the default - timeout (specified by the `read_timeout` attribute). - """ - - assert self.read_endpoint is not None, "Device not connected." - - if timeout is None: - timeout = self.read_timeout - - # Attempt to read packets until timeout, or when we identify the right id. - timeout_time = time.time() + timeout - - while time.time() < timeout_time: - # read response from endpoint, and keep reading until the packet is smaller than the max - # packet size: if the packet is that size, it means that there may be more data to read. - resp = bytearray() - last_packet: Optional[bytearray] = None - while True: # read while we have data, and while the last packet is the max size. - last_packet = self._read_packet() - if last_packet is not None: - resp += last_packet - if last_packet is None or len(last_packet) != self.read_endpoint.wMaxPacketSize: - break - - if resp == "": - continue - - logger.debug("Received data: %s", resp) - return resp - - raise TimeoutError("Timeout while reading.") - - def get_available_devices(self) -> List["usb.core.Device"]: - """ Get a list of available devices that match the specified vendor and product IDs, and serial - number and address if specified. """ - - found_devices = libusb_package.find( - idVendor=self.id_vendor, - idProduct=self.id_product, - find_all=True - ) - devices: List["usb.core.Device"] = [] - for dev in found_devices: - if self.address is not None: - if dev.address is None: - raise RuntimeError("A device address was specified, but the backend used for PyUSB does " - "not support device addresses.") - - if dev.address != self.address: - continue - - if self.serial_number is not None: - if dev.serial_number is None: - raise RuntimeError("A serial number was specified, but the device does not have a serial " - "number.") - - if dev.serial_number != self.serial_number: - continue - - devices.append(dev) - - return devices - - def list_available_devices(self) -> None: - """ Utility to list all devices that match the specified vendor and product IDs, and serial - number and address if specified. You can use this to discover the serial number and address of - your device, if using multiple. Note that devices may not have a unique serial number. """ - - for dev in self.get_available_devices(): - print(dev) - - async def setup(self): - """ Initialize the USB connection to the machine.""" - - if not USE_USB: - raise RuntimeError("USB is not enabled. Please install pyusb.") - - if self.dev is not None: - logging.warning("Already initialized. Please call stop() first.") - return - - logger.info("Finding USB device...") - - devices = self.get_available_devices() - if len(devices) == 0: - raise RuntimeError("USB device not found.") - if len(devices) > 1: - logging.warning("Multiple devices found. Using the first one.") - self.dev = devices[0] - - logger.info("Found USB device.") - - # set the active configuration. With no arguments, the first - # configuration will be the active one - self.dev.set_configuration() - - cfg = self.dev.get_active_configuration() - intf = cfg[(0,0)] - - self.write_endpoint = usb.util.find_descriptor( - intf, - custom_match = \ - lambda e: \ - usb.util.endpoint_direction(e.bEndpointAddress) == \ - usb.util.ENDPOINT_OUT) - - self.read_endpoint = usb.util.find_descriptor( - intf, - custom_match = \ - lambda e: \ - usb.util.endpoint_direction(e.bEndpointAddress) == \ - usb.util.ENDPOINT_IN) - - logger.info("Found endpoints. \nWrite:\n %s \nRead:\n %s", self.write_endpoint, - self.read_endpoint) - - # Empty the read buffer. - while self._read_packet() is not None: - pass - - async def stop(self): - """ Close the USB connection to the machine. """ - - if self.dev is None: - raise ValueError("USB device was not connected.") - logging.warning("Closing connection to USB device.") - usb.util.dispose_resources(self.dev) - self.dev = None diff --git a/pylabrobot/liquid_handling/backends/__init__.py b/pylabrobot/liquid_handling/backends/__init__.py index dc3c924857..b10784d64f 100644 --- a/pylabrobot/liquid_handling/backends/__init__.py +++ b/pylabrobot/liquid_handling/backends/__init__.py @@ -1,13 +1,12 @@ from .backend import LiquidHandlerBackend +from .chatterbox import LiquidHandlerChatterboxBackend from .chatterbox_backend import ChatterBoxBackend from .serializing_backend import SerializingBackend, SerializingSavingBackend # many rely on this from .websocket import WebSocketBackend -from .USBBackend import USBBackend from .hamilton.STAR import STAR from .hamilton.vantage import Vantage from .http import HTTPBackend from .opentrons_backend import OpentronsBackend from .saver_backend import SaverBackend - from .tecan.EVO import EVO diff --git a/pylabrobot/liquid_handling/backends/backend.py b/pylabrobot/liquid_handling/backends/backend.py index 2438581935..d505a6bd17 100644 --- a/pylabrobot/liquid_handling/backends/backend.py +++ b/pylabrobot/liquid_handling/backends/backend.py @@ -1,10 +1,10 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import List, Type, Optional +from typing import List, Optional, Union -from pylabrobot.machine import MachineBackend -from pylabrobot.resources import Resource +from pylabrobot.machines.backends import MachineBackend +from pylabrobot.resources import Deck, Resource from pylabrobot.liquid_handling.standard import ( Pickup, PickupTipRack, @@ -12,8 +12,10 @@ DropTipRack, Aspiration, AspirationPlate, + AspirationContainer, Dispense, DispensePlate, + DispenseContainer, Move, ) @@ -29,6 +31,24 @@ class LiquidHandlerBackend(MachineBackend, metaclass=ABCMeta): setup_finished: Whether the backend has been set up. """ + def __init__(self): + self.setup_finished = False + self._deck: Optional[Deck] = None + + def set_deck(self, deck: Deck): + """ Set the deck for the robot. Called automatically by `LiquidHandler.setup` or can be called + manually if interacting with the backend directly. A deck must be set before setup. """ + self._deck = deck + + @property + def deck(self) -> Deck: + assert self._deck is not None, "Deck not set" + return self._deck + + async def setup(self): + """ Set up the robot. This method should be called before any other method is called. """ + assert self._deck is not None, "Deck not set" + async def assigned_resource_callback(self, resource: Resource): """ Called when a new resource was assigned to the robot. @@ -77,50 +97,17 @@ async def drop_tips96(self, drop: DropTipRack): """ Drop tips to the specified resource using CoRe 96. """ @abstractmethod - async def aspirate96(self, aspiration: AspirationPlate): + async def aspirate96(self, aspiration: Union[AspirationPlate, AspirationContainer]): """ Aspirate from all wells in 96 well plate. """ @abstractmethod - async def dispense96(self, dispense: DispensePlate): + async def dispense96(self, dispense: Union[DispensePlate, DispenseContainer]): """ Dispense to all wells in 96 well plate. """ @abstractmethod async def move_resource(self, move: Move): """ Move a resource to a new location. """ - def serialize(self): - """ Serialize the backend so that an equivalent backend can be created by passing the dict - as kwargs to the initializer. The dict must contain a key "type" that specifies the type of - backend to create. This key will be removed from the dict before passing it to the initializer. - """ - - return { - "type": self.__class__.__name__, - } - - @classmethod - def deserialize(cls, data: dict) -> LiquidHandlerBackend: - """ Deserialize the backend. Unless a custom serialization method is implemented, this method - should not be overridden. """ - - # Recursively find a subclass with the correct name - def find_subclass(cls: Type[LiquidHandlerBackend], name: str) -> \ - Optional[Type[LiquidHandlerBackend]]: - if cls.__name__ == name: - return cls - for subclass in cls.__subclasses__(): - subclass_ = find_subclass(subclass, name) - if subclass_ is not None: - return subclass_ - return None - - subclass = find_subclass(cls, data["type"]) - if subclass is None: - raise ValueError(f"Could not find subclass with name {data['type']}") - - del data["type"] - return subclass(**data) - async def prepare_for_manual_channel_operation(self, channel: int): """ Prepare the robot for manual operation. """ diff --git a/pylabrobot/liquid_handling/backends/chatterbox.py b/pylabrobot/liquid_handling/backends/chatterbox.py new file mode 100644 index 0000000000..bb6dbb2b74 --- /dev/null +++ b/pylabrobot/liquid_handling/backends/chatterbox.py @@ -0,0 +1,218 @@ +# pylint: disable=unused-argument, inconsistent-quotes + +from typing import List, Union + +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.resources import Resource +from pylabrobot.liquid_handling.standard import ( + Pickup, + PickupTipRack, + Drop, + DropTipRack, + Aspiration, + AspirationPlate, + AspirationContainer, + Dispense, + DispensePlate, + DispenseContainer, + Move +) + + +class LiquidHandlerChatterboxBackend(LiquidHandlerBackend): + """ Chatter box backend for device-free testing. Prints out all operations. """ + + _pip_length = 5 + _vol_length = 8 + _resource_length = 20 + _offset_length = 16 + _flow_rate_length = 10 + _blowout_length = 10 + _lld_z_length = 10 + _kwargs_length = 15 + _tip_type_length = 12 + _max_volume_length = 16 + _fitting_depth_length = 20 + _tip_length_length = 16 + # _pickup_method_length = 20 + _filter_length = 10 + + def __init__(self, num_channels: int = 8): + """ Initialize a chatter box backend. """ + super().__init__() + self._num_channels = num_channels + + async def setup(self): + await super().setup() + print("Setting up the liquid handler.") + + async def stop(self): + print("Stopping the liquid handler.") + + def serialize(self) -> dict: + return {**super().serialize(), "num_channels": self.num_channels} + + @property + def num_channels(self) -> int: + return self._num_channels + + async def assigned_resource_callback(self, resource: Resource): + print(f"Resource {resource.name} was assigned to the liquid handler.") + + async def unassigned_resource_callback(self, name: str): + print(f"Resource {name} was unassigned from the liquid handler.") + + async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): + print("Picking up tips:") + header = ( + f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " + f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{'tip type':<{LiquidHandlerChatterboxBackend._tip_type_length}} " + f"{'max volume (µL)':<{LiquidHandlerChatterboxBackend._max_volume_length}} " + f"{'fitting depth (mm)':<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " + f"{'tip length (mm)':<{LiquidHandlerChatterboxBackend._tip_length_length}} " + # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " + f"{'filter':<{LiquidHandlerChatterboxBackend._filter_length}}" + ) + print(header) + + for op, channel in zip(ops, use_channels): + offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" + row = ( + f" p{channel}: " + f"{op.resource.name[-30:]:<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{op.tip.__class__.__name__:<{LiquidHandlerChatterboxBackend._tip_type_length}} " + f"{op.tip.maximal_volume:<{LiquidHandlerChatterboxBackend._max_volume_length}} " + f"{op.tip.fitting_depth:<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " + f"{op.tip.total_tip_length:<{LiquidHandlerChatterboxBackend._tip_length_length}} " + # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " + f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerChatterboxBackend._filter_length}}" + ) + print(row) + + async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): + print("Dropping tips:") + header = ( + f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " + f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{'tip type':<{LiquidHandlerChatterboxBackend._tip_type_length}} " + f"{'max volume (µL)':<{LiquidHandlerChatterboxBackend._max_volume_length}} " + f"{'fitting depth (mm)':<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " + f"{'tip length (mm)':<{LiquidHandlerChatterboxBackend._tip_length_length}} " + # f"{'pickup method':<{ChatterboxBackend._pickup_method_length}} " + f"{'filter':<{LiquidHandlerChatterboxBackend._filter_length}}" + ) + print(header) + + for op, channel in zip(ops, use_channels): + offset = f"{round(op.offset.x, 1)},{round(op.offset.y, 1)},{round(op.offset.z, 1)}" + row = ( + f" p{channel}: " + f"{op.resource.name[-30:]:<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{op.tip.__class__.__name__:<{LiquidHandlerChatterboxBackend._tip_type_length}} " + f"{op.tip.maximal_volume:<{LiquidHandlerChatterboxBackend._max_volume_length}} " + f"{op.tip.fitting_depth:<{LiquidHandlerChatterboxBackend._fitting_depth_length}} " + f"{op.tip.total_tip_length:<{LiquidHandlerChatterboxBackend._tip_length_length}} " + # f"{str(op.tip.pickup_method)[-20:]:<{ChatterboxBackend._pickup_method_length}} " + f"{'Yes' if op.tip.has_filter else 'No':<{LiquidHandlerChatterboxBackend._filter_length}}" + ) + print(row) + + async def aspirate(self, ops: List[Aspiration], use_channels: List[int], **backend_kwargs): + print("Aspirating:") + header = ( + f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " + f"{'vol(ul)':<{LiquidHandlerChatterboxBackend._vol_length}} " + f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{'flow rate':<{LiquidHandlerChatterboxBackend._flow_rate_length}} " + f"{'blowout':<{LiquidHandlerChatterboxBackend._blowout_length}} " + f"{'lld_z':<{LiquidHandlerChatterboxBackend._lld_z_length}} " + # f"{'liquids':<20}" # TODO: add liquids + ) + for key in backend_kwargs: + header += f"{key:<{LiquidHandlerChatterboxBackend._kwargs_length}} "[-16:] + print(header) + + for o, p in zip(ops, use_channels): + offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" + row = ( + f" p{p}: " + f"{o.volume:<{LiquidHandlerChatterboxBackend._vol_length}} " + f"{o.resource.name[-20:]:<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{str(o.flow_rate):<{LiquidHandlerChatterboxBackend._flow_rate_length}} " + f"{str(o.blow_out_air_volume):<{LiquidHandlerChatterboxBackend._blowout_length}} " + f"{str(o.liquid_height):<{LiquidHandlerChatterboxBackend._lld_z_length}} " + # f"{o.liquids if o.liquids is not None else 'none'}" + ) + for key, value in backend_kwargs.items(): + if isinstance(value, list) and all(isinstance(v, bool) for v in value): + value = "".join("T" if v else "F" for v in value) + if isinstance(value, list): + value = "".join(map(str, value)) + row += f" {value:<15}" + print(row) + + async def dispense(self, ops: List[Dispense], use_channels: List[int], **backend_kwargs): + print("Dispensing:") + header = ( + f"{'pip#':<{LiquidHandlerChatterboxBackend._pip_length}} " + f"{'vol(ul)':<{LiquidHandlerChatterboxBackend._vol_length}} " + f"{'resource':<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{'offset':<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{'flow rate':<{LiquidHandlerChatterboxBackend._flow_rate_length}} " + f"{'blowout':<{LiquidHandlerChatterboxBackend._blowout_length}} " + f"{'lld_z':<{LiquidHandlerChatterboxBackend._lld_z_length}} " + # f"{'liquids':<20}" # TODO: add liquids + ) + for key in backend_kwargs: + header += f"{key:<{LiquidHandlerChatterboxBackend._kwargs_length}} "[-16:] + print(header) + + for o, p in zip(ops, use_channels): + offset = f"{round(o.offset.x, 1)},{round(o.offset.y, 1)},{round(o.offset.z, 1)}" + row = ( + f" p{p}: " + f"{o.volume:<{LiquidHandlerChatterboxBackend._vol_length}} " + f"{o.resource.name[-20:]:<{LiquidHandlerChatterboxBackend._resource_length}} " + f"{offset:<{LiquidHandlerChatterboxBackend._offset_length}} " + f"{str(o.flow_rate):<{LiquidHandlerChatterboxBackend._flow_rate_length}} " + f"{str(o.blow_out_air_volume):<{LiquidHandlerChatterboxBackend._blowout_length}} " + f"{str(o.liquid_height):<{LiquidHandlerChatterboxBackend._lld_z_length}} " + # f"{o.liquids if o.liquids is not None else 'none'}" + ) + for key, value in backend_kwargs.items(): + if isinstance(value, list) and all(isinstance(v, bool) for v in value): + value = ''.join('T' if v else 'F' for v in value) + if isinstance(value, list): + value = "".join(map(str, value)) + row += f" {value:<{LiquidHandlerChatterboxBackend._kwargs_length}}" + print(row) + + async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): + print(f"Picking up tips from {pickup.resource.name}.") + + async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): + print(f"Dropping tips to {drop.resource.name}.") + + async def aspirate96(self, aspiration: Union[AspirationPlate, AspirationContainer]): + if isinstance(aspiration, AspirationPlate): + resource = aspiration.wells[0].parent + else: + resource = aspiration.container + print(f"Aspirating {aspiration.volume} from {resource}.") + + async def dispense96(self, dispense: Union[DispensePlate, DispenseContainer]): + if isinstance(dispense, DispensePlate): + resource = dispense.wells[0].parent + else: + resource = dispense.container + print(f"Dispensing {dispense.volume} to {resource}.") + + async def move_resource(self, move: Move, **backend_kwargs): + print(f"Moving {move}.") diff --git a/pylabrobot/liquid_handling/backends/chatterbox_backend.py b/pylabrobot/liquid_handling/backends/chatterbox_backend.py index ea8a054299..e17e30d2de 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox_backend.py +++ b/pylabrobot/liquid_handling/backends/chatterbox_backend.py @@ -1,73 +1,7 @@ -# pylint: disable=unused-argument +# pylint: disable=unused-argument, inconsistent-quotes -from typing import List - -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.resources import Resource -from pylabrobot.liquid_handling.standard import ( - Pickup, - PickupTipRack, - Drop, - DropTipRack, - Aspiration, - AspirationPlate, - Dispense, - DispensePlate, - Move -) - - -class ChatterBoxBackend(LiquidHandlerBackend): - """ Chatter box backend for 'How to Open Source' """ +class ChatterBoxBackend: def __init__(self, num_channels: int = 8): - """ Initialize a chatter box backend. """ - super().__init__() - self._num_channels = num_channels - - async def setup(self): - await super().setup() - print("Setting up the robot.") - - async def stop(self): - await super().stop() - print("Stopping the robot.") - - @property - def num_channels(self) -> int: - return self._num_channels - - async def assigned_resource_callback(self, resource: Resource): - print(f"Resource {resource.name} was assigned to the robot.") - - async def unassigned_resource_callback(self, name: str): - print(f"Resource {name} was unassigned from the robot.") - - async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int], **backend_kwargs): - print(f"Picking up tips {ops}.") - - async def drop_tips(self, ops: List[Drop], use_channels: List[int], **backend_kwargs): - print(f"Dropping tips {ops}.") - - async def aspirate(self, ops: List[Aspiration], use_channels: List[int], **backend_kwargs): - print(f"Aspirating {ops}.") - - async def dispense(self, ops: List[Dispense], use_channels: List[int], **backend_kwargs): - print(f"Dispensing {ops}.") - - async def pick_up_tips96(self, pickup: PickupTipRack, **backend_kwargs): - print(f"Picking up tips from {pickup.resource.name}.") - - async def drop_tips96(self, drop: DropTipRack, **backend_kwargs): - print(f"Dropping tips to {drop.resource.name}.") - - async def aspirate96(self, aspiration: AspirationPlate): - plate = aspiration.wells[0].parent - print(f"Aspirating {aspiration.volume} from {plate}.") - - async def dispense96(self, dispense: DispensePlate): - plate = dispense.wells[0].parent - print(f"Dispensing {dispense.volume} to {plate}.") - - async def move_resource(self, move: Move, **backend_kwargs): - print(f"Moving {move}.") + raise NotImplementedError("ChatterBoxBackend is deprecated. " + "Use LiquidHandlerChatterboxBackend instead.") diff --git a/pylabrobot/liquid_handling/backends/chatterbox_backend_tests.py b/pylabrobot/liquid_handling/backends/chatterbox_tests.py similarity index 76% rename from pylabrobot/liquid_handling/backends/chatterbox_backend_tests.py rename to pylabrobot/liquid_handling/backends/chatterbox_tests.py index 2389185847..8478b8f6da 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/chatterbox_tests.py @@ -1,20 +1,20 @@ import unittest from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.chatterbox_backend import ChatterBoxBackend -from pylabrobot.resources import Cos_96_EZWash, HTF_L, Coordinate +from pylabrobot.liquid_handling.backends.chatterbox import LiquidHandlerChatterboxBackend +from pylabrobot.resources import Cor_96_wellplate_360ul_Fb, HTF_L, Coordinate from pylabrobot.resources.hamilton import STARLetDeck -class ChatterBoxBackendTests(unittest.IsolatedAsyncioTestCase): - """ Tests for setup and stop """ +class ChatterboxBackendTests(unittest.IsolatedAsyncioTestCase): + """ Tests for chatterbox backend """ def setUp(self) -> None: self.deck = STARLetDeck() - self.backend = ChatterBoxBackend(num_channels=8) + self.backend = LiquidHandlerChatterboxBackend(num_channels=8) self.lh = LiquidHandler(self.backend, deck=self.deck) self.tip_rack = HTF_L(name="tip_rack") self.deck.assign_child_resource(self.tip_rack, rails=3) - self.plate = Cos_96_EZWash(name="plate") + self.plate = Cor_96_wellplate_360ul_Fb(name="plate") self.deck.assign_child_resource(self.plate, rails=9) async def asyncSetUp(self) -> None: @@ -41,11 +41,11 @@ async def test_drop_tips96(self): async def test_aspirate(self): await self.lh.pick_up_tips(self.tip_rack["A1"]) - await self.lh.aspirate(self.plate["A1"], vols=10) + await self.lh.aspirate(self.plate["A1"], vols=[10]) async def test_dispense(self): await self.lh.pick_up_tips(self.tip_rack["A1"]) - await self.lh.dispense(self.plate["A1"], vols=10) + await self.lh.dispense(self.plate["A1"], vols=[10]) async def test_aspirate96(self): await self.lh.pick_up_tips96(self.tip_rack) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR.py b/pylabrobot/liquid_handling/backends/hamilton/STAR.py index 836fea27b3..d5c3f9ed54 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR.py @@ -11,6 +11,7 @@ import re from typing import Callable, Dict, List, Literal, Optional, Sequence, Type, TypeVar, Union, cast +from pylabrobot import audio from pylabrobot.liquid_handling.backends.hamilton.base import HamiltonLiquidHandler from pylabrobot.liquid_handling.errors import ChannelizedError from pylabrobot.liquid_handling.liquid_classes.hamilton import ( @@ -22,8 +23,10 @@ DropTipRack, Aspiration, AspirationPlate, + AspirationContainer, Dispense, DispensePlate, + DispenseContainer, GripDirection, Move ) @@ -34,9 +37,11 @@ HasTipError, NoTipError ) +from pylabrobot.resources.hamilton.hamilton_decks import STAR_SIZE_X, STARLET_SIZE_X from pylabrobot.resources.liquid import Liquid from pylabrobot.resources.ml_star import HamiltonTip, TipDropMethod, TipPickupMethod, TipSize -from pylabrobot import audio +from pylabrobot.resources.resource_holder import get_child_location +from pylabrobot.utils.linalg import matrix_vector_multiply_3x3 T = TypeVar("T") @@ -74,7 +79,11 @@ def _fill_in_defaults(val: Optional[List[T]], default: List[T]) -> List[T]: # if the val is a list, it must be of the correct length. if len(val) != len(default): raise ValueError(f"Value length must equal num operations ({len(default)}), but is {val}") - # if the val is a list of the correct length, the values must be of the right type. + # replace None values in list with default values. + val = [v if v is not None else d for v, d in zip(val, default)] + # the values must be of the right type. automatically handle int and float. + if t in [int, float]: + return [t(v) for v in val] # type: ignore if not all(isinstance(v, t) for v in val): raise ValueError(f"Value must be a list of {t}, but is {val}") # the value is ready to be used. @@ -841,10 +850,10 @@ def trace_information_to_string(module_identifier: str, trace_information: int) 55: "Y-drive blocked", 56: "Y-drive not initialized", 57: "Y-drive movement error", - 60: "X-drive blocked", - 61: "X-drive not initialized", - 62: "X-drive movement error", - 63: "X-drive limit stop not found", + 60: "Z-drive blocked", + 61: "Z-drive not initialized", + 62: "Z-drive movement error", + 63: "Z-drive limit stop not found", 70: "No liquid level found (possibly because no liquid was present)", 71: "Not enough liquid present (Immersion depth or surface following position possiby" "below minimal access range)", @@ -1063,6 +1072,7 @@ class STAR(HamiltonLiquidHandler): def __init__( self, device_address: Optional[int] = None, + serial_number: Optional[str] = None, packet_read_timeout: int = 3, read_timeout: int = 30, write_timeout: int = 30, @@ -1072,10 +1082,11 @@ def __init__( Args: device_address: the USB device address of the Hamilton STAR. Only useful if using more than one Hamilton machine over USB. + serial_number: the serial number of the Hamilton STAR. Only useful if using more than one + Hamilton machine over USB. packet_read_timeout: timeout in seconds for reading a single packet. read_timeout: timeout in seconds for reading a full response. write_timeout: timeout in seconds for writing a command. - num_channels: the number of pipette channels present on the robot. """ super().__init__( @@ -1083,13 +1094,26 @@ def __init__( packet_read_timeout=packet_read_timeout, read_timeout=read_timeout, write_timeout=write_timeout, - id_product=0x8000) + id_product=0x8000, + serial_number=serial_number + ) + + self.iswap_installed: Optional[bool] = None + self.autoload_installed: Optional[bool] = None + self.core96_head_installed: Optional[bool] = None self._iswap_parked: Optional[bool] = None self._num_channels: Optional[int] = None self._core_parked: Optional[bool] = None self._extended_conf: Optional[dict] = None self._traversal_height: float = 245.0 + self.core_adjustment = Coordinate.zero() + self._unsafe = UnSafe(self) + + @property + def unsafe(self) -> "UnSafe": + """ Actions that have a higher risk of damaging the robot. Use with care! """ + return self._unsafe @property def num_channels(self) -> int: @@ -1122,14 +1146,6 @@ def extended_conf(self) -> dict: raise RuntimeError("has not loaded extended_conf, forgot to call `setup`?") return self._extended_conf - def serialize(self) -> dict: - return { - **super().serialize(), - "packet_read_timeout": self.packet_read_timeout, - "read_timeout": self.read_timeout, - "write_timeout": self.write_timeout, - } - @property def iswap_parked(self) -> bool: return self._iswap_parked is True @@ -1201,10 +1217,13 @@ def _parse_response(self, resp: str, fmt: str) -> dict: """ Parse a response from the machine. """ return parse_star_fw_string(resp, fmt) - async def setup(self): - """ setup + async def setup(self, skip_autoload=False, skip_iswap=False, skip_core96_head=False): + """ Creates a USB connection and finds read/write interfaces. - Creates a USB connection and finds read/write interfaces. + Args: + skip_autoload: if True, skip initializing the autoload module, if applicable. + skip_iswap: if True, skip initializing the iSWAP module, if applicable. + skip_core96_head: if True, skip initializing the CoRe 96 head module, if applicable. """ await super().setup() @@ -1221,7 +1240,7 @@ async def setup(self): "0" * (16 - len(left_x_drive_configuration_byte_1)) left_x_drive_configuration_byte_1 = left_x_drive_configuration_byte_1[2:] configuration_data1 = bin(conf["kb"]).split("b")[-1].zfill(8) - autoload_configuration_byte = configuration_data1[-3] + autoload_configuration_byte = configuration_data1[-4] # Identify installations self.autoload_installed = autoload_configuration_byte == "1" self.core96_head_installed = left_x_drive_configuration_byte_1[2] == "1" @@ -1244,19 +1263,19 @@ async def setup(self): begin_of_tip_deposit_process=int(self._traversal_height * 10), end_of_tip_deposit_process=1220, z_position_at_end_of_a_command=3600, - tip_pattern=[True], # [True] * 8 + tip_pattern=[True] * self.num_channels, tip_type=4, # TODO: get from tip types discarding_method=0 ) - if self.autoload_installed: + if self.autoload_installed and not skip_autoload: autoload_initialized = await self.request_autoload_initialization_status() if not autoload_initialized: await self.initialize_autoload() await self.park_autoload() - if self.iswap_installed: + if self.iswap_installed and not skip_iswap: iswap_initialized = await self.request_iswap_initialization_status() if not iswap_initialized: await self.initialize_iswap() @@ -1264,7 +1283,7 @@ async def setup(self): await self.park_iswap(minimum_traverse_height_at_beginning_of_a_command= int(self._traversal_height * 10)) - if self.core96_head_installed: + if self.core96_head_installed and not skip_core96_head: core96_head_initialized = await self.request_core_96_head_initialization_status() if not core96_head_initialized: await self.initialize_core_96_head( @@ -1280,6 +1299,10 @@ async def pick_up_tips( self, ops: List[Pickup], use_channels: List[int], + begin_tip_pick_up_process: Optional[float] = None, + end_tip_pick_up_process: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + pickup_method: Optional[TipPickupMethod] = None, ): """ Pick up tips from a resource. """ @@ -1292,27 +1315,40 @@ async def pick_up_tips( raise ValueError("Cannot mix tips with different tip types.") ttti = (await self.get_ttti(list(tips)))[0] - max_z = max(op.resource.get_absolute_location().z + \ - (op.offset.z if op.offset is not None else 0) for op in ops) + max_z = max(op.resource.get_absolute_location().z + op.offset.z for op in ops) max_total_tip_length = max(op.tip.total_tip_length for op in ops) max_tip_length = max((op.tip.total_tip_length-op.tip.fitting_depth) for op in ops) - if self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: - # not sure why this is necessary, but it is according to log files and experiments + # not sure why this is necessary, but it is according to log files and experiments + if self._get_hamilton_tip([op.resource for op in ops]).tip_size == TipSize.LOW_VOLUME: + max_tip_length += 2 + elif self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: max_tip_length -= 2 + tip = ops[0].tip + if not isinstance(tip, HamiltonTip): + raise TypeError("Tip type must be HamiltonTip.") + + begin_tip_pick_up_process = round((max_z + max_total_tip_length)*10) \ + if begin_tip_pick_up_process is None else int(begin_tip_pick_up_process*10) + end_tip_pick_up_process = round((max_z + max_tip_length)*10) \ + if end_tip_pick_up_process is None else round(end_tip_pick_up_process*10) + minimum_traverse_height_at_beginning_of_a_command = round(self._traversal_height * 10) \ + if minimum_traverse_height_at_beginning_of_a_command is None \ + else round(minimum_traverse_height_at_beginning_of_a_command * 10) + pickup_method = pickup_method or tip.pickup_method + try: - tip = ops[0].tip - assert isinstance(tip, HamiltonTip), "Tip type must be HamiltonTip." return await self.pick_up_tip( x_positions=x_positions, y_positions=y_positions, tip_pattern=channels_involved, tip_type_idx=ttti, - begin_tip_pick_up_process=int((max_z + max_total_tip_length)*10), - end_tip_pick_up_process=int((max_z + max_tip_length)*10), - minimum_traverse_height_at_beginning_of_a_command=int(self._traversal_height * 10), - pickup_method=tip.pickup_method, + begin_tip_pick_up_process=begin_tip_pick_up_process, + end_tip_pick_up_process=end_tip_pick_up_process, + minimum_traverse_height_at_beginning_of_a_command=\ + minimum_traverse_height_at_beginning_of_a_command, + pickup_method=pickup_method, ) except STARFirmwareError as e: if plr_e := convert_star_firmware_error_to_plr_error(e): @@ -1324,6 +1360,10 @@ async def drop_tips( ops: List[Drop], use_channels: List[int], drop_method: Optional[TipDropMethod] = None, + begin_tip_deposit_process: Optional[float] = None, + end_tip_deposit_process: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_end_of_a_command: Optional[float] = None, ): """ Drop tips to a resource. @@ -1343,27 +1383,37 @@ async def drop_tips( self._ops_to_fw_positions(ops, use_channels) # get highest z position - max_z = max(op.resource.get_absolute_location().z + \ - (op.offset.z if op.offset is not None else 0) for op in ops) + max_z = max(op.resource.get_absolute_location().z + op.offset.z for op in ops) if drop_method == TipDropMethod.PLACE_SHIFT: # magic values empirically found in https://github.com/PyLabRobot/pylabrobot/pull/63 - begin_tip_deposit_process = int((max_z+59.9)*10) - end_tip_deposit_process = int((max_z+49.9)*10) + begin_tip_deposit_process = round((max_z+59.9)*10) \ + if begin_tip_deposit_process is None else round(begin_tip_deposit_process*10) + end_tip_deposit_process = round((max_z+49.9)*10) \ + if end_tip_deposit_process is None else round(end_tip_deposit_process*10) else: max_total_tip_length = max(op.tip.total_tip_length for op in ops) max_tip_length = max((op.tip.total_tip_length-op.tip.fitting_depth) for op in ops) - begin_tip_deposit_process=int((max_z + max_total_tip_length)*10) - end_tip_deposit_process=int((max_z + max_tip_length)*10) + begin_tip_deposit_process=round((max_z + max_total_tip_length)*10) \ + if begin_tip_deposit_process is None else round(begin_tip_deposit_process*10) + end_tip_deposit_process=round((max_z + max_tip_length)*10) \ + if end_tip_deposit_process is None else round(end_tip_deposit_process*10) + + minimum_traverse_height_at_beginning_of_a_command = round(self._traversal_height * 10) \ + if minimum_traverse_height_at_beginning_of_a_command is None \ + else round(minimum_traverse_height_at_beginning_of_a_command * 10) + z_position_at_end_of_a_command = round(self._traversal_height * 10) \ + if z_position_at_end_of_a_command is None else round(z_position_at_end_of_a_command * 10) try: return await self.discard_tip( x_positions=x_positions, y_positions=y_positions, tip_pattern=channels_involved, - begin_tip_deposit_process= begin_tip_deposit_process, - end_tip_deposit_process= end_tip_deposit_process, - minimum_traverse_height_at_beginning_of_a_command=int(self._traversal_height * 10), - z_position_at_end_of_a_command=int(self._traversal_height * 10), + begin_tip_deposit_process=begin_tip_deposit_process, + end_tip_deposit_process=end_tip_deposit_process, + minimum_traverse_height_at_beginning_of_a_command=\ + minimum_traverse_height_at_beginning_of_a_command, + z_position_at_end_of_a_command=z_position_at_end_of_a_command, discarding_method=drop_method ) except STARFirmwareError as e: @@ -1393,45 +1443,45 @@ async def aspirate( use_channels: List[int], jet: Optional[List[bool]] = None, blow_out: Optional[List[bool]] = None, - - lld_search_height: Optional[List[int]] = None, - clot_detection_height: Optional[List[int]] = None, - pull_out_distance_transport_air: Optional[List[int]] = None, - second_section_height: Optional[List[int]] = None, - second_section_ratio: Optional[List[int]] = None, - minimum_height: Optional[List[int]] = None, - immersion_depth: Optional[List[int]] = None, + lld_search_height: Optional[List[float]] = None, + clot_detection_height: Optional[List[float]] = None, + pull_out_distance_transport_air: Optional[List[float]] = None, + second_section_height: Optional[List[float]] = None, + second_section_ratio: Optional[List[float]] = None, + minimum_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, immersion_depth_direction: Optional[List[int]] = None, - surface_following_distance: Optional[List[int]] = None, - transport_air_volume: Optional[List[int]] = None, - pre_wetting_volume: Optional[List[int]] = None, + surface_following_distance: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + pre_wetting_volume: Optional[List[float]] = None, lld_mode: Optional[List[LLDMode]] = None, gamma_lld_sensitivity: Optional[List[int]] = None, dp_lld_sensitivity: Optional[List[int]] = None, - aspirate_position_above_z_touch_off: Optional[List[int]] = None, - detection_height_difference_for_dual_lld: Optional[List[int]] = None, - swap_speed: Optional[List[int]] = None, - settling_time: Optional[List[int]] = None, - homogenization_volume: Optional[List[int]] = None, - homogenization_cycles: Optional[List[int]] = None, - homogenization_position_from_liquid_surface: Optional[List[int]] = None, - homogenization_speed: Optional[List[int]] = None, - homogenization_surface_following_distance: Optional[List[int]] = None, + aspirate_position_above_z_touch_off: Optional[List[float]] = None, + detection_height_difference_for_dual_lld: Optional[List[float]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_volume: Optional[List[float]] = None, + mix_cycles: Optional[List[int]] = None, + mix_position_from_liquid_surface: Optional[List[float]] = None, + mix_speed: Optional[List[float]] = None, + mix_surface_following_distance: Optional[List[float]] = None, limit_curve_index: Optional[List[int]] = None, use_2nd_section_aspiration: Optional[List[bool]] = None, - retract_height_over_2nd_section_to_empty_tip: Optional[List[int]] = None, - dispensation_speed_during_emptying_tip: Optional[List[int]] = None, - dosing_drive_speed_during_2nd_section_search: Optional[List[int]] = None, - z_drive_speed_during_2nd_section_search: Optional[List[int]] = None, - cup_upper_edge: Optional[List[int]] = None, - ratio_liquid_rise_to_tip_deep_in: Optional[List[int]] = None, - immersion_depth_2nd_section: Optional[List[int]] = None, + retract_height_over_2nd_section_to_empty_tip: Optional[List[float]] = None, + dispensation_speed_during_emptying_tip: Optional[List[float]] = None, + dosing_drive_speed_during_2nd_section_search: Optional[List[float]] = None, + z_drive_speed_during_2nd_section_search: Optional[List[float]] = None, + cup_upper_edge: Optional[List[float]] = None, + ratio_liquid_rise_to_tip_deep_in: Optional[List[float]] = None, + immersion_depth_2nd_section: Optional[List[float]] = None, - minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, - min_z_endpos: Optional[int] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + min_z_endpos: Optional[float] = None, - hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, + liquid_surfaces_no_lld: Optional[List[float]] = None, ): """ Aspirate liquid from the specified channels. @@ -1439,11 +1489,6 @@ async def aspirate( the aspirations. For all list parameters, the length of the list must be equal to the number of operations. - .. warning:: The parameters in this method, with the exception of `ops` and `use_channels`, - expect units of tenths of 'millimeters' (i.e. 10 = 1 mm), or tenths of 'microliters' (i.e. 10 - = 1 ul), or tenths of seconds. Speeds are in 0.1ul/s. This is a deviation from the rest of the - API, which uses SI units. This is because the Hamilton API uses these units. - Args: ops: The aspiration operations to perform. use_channels: The channels to use for the operations. @@ -1473,15 +1518,15 @@ async def aspirate( the bottom of the well (presumably) to aspirate from. detection_height_difference_for_dual_lld: Difference between the gamma and DP LLD heights if the LLD mode is DUAL. - swap_speed: Unknown. - settling_time: The time to wait after homogenization. - homogenization_volume: The volume to aspirate for homogenization. - homogenization_cycles: The number of cycles to perform for homogenization. - homogenization_position_from_liquid_surface: The height to aspirate from for homogenization + swap_speed: Swap speed (on leaving liquid) [1mm/s]. Must be between 3 and 1600. Default 100. + settling_time: The time to wait after mix. + mix_volume: The volume to aspirate for mix. + mix_cycles: The number of cycles to perform for mix. + mix_position_from_liquid_surface: The height to aspirate from for mix (LLD or absolute terms). - homogenization_speed: The speed to aspirate at for homogenization. - homogenization_surface_following_distance: The distance to follow the liquid surface for - homogenization. + mix_speed: The speed to aspirate at for mix. + mix_surface_following_distance: The distance to follow the liquid surface for + mix. limit_curve_index: The index of the limit curve to use. use_2nd_section_aspiration: Whether to use the second section of aspiration. @@ -1500,6 +1545,8 @@ async def aspirate( hamilton_liquid_classes: Override the default liquid classes. See pylabrobot/liquid_handling/liquid_classes/hamilton/star.py + liquid_surface_no_lld: Liquid surface at function without LLD [mm]. Must be between 0 + and 360. Defaults to well bottom + liquid height. Should use absolute z. """ x_positions, y_positions, channels_involved = \ @@ -1533,27 +1580,27 @@ async def aspirate( self._assert_valid_resources([op.resource for op in ops]) # correct volumes using the liquid class - for op, hlc in zip(ops, hamilton_liquid_classes): - op.volume = hlc.compute_corrected_volume(op.volume) if hlc is not None else op.volume + volumes = [hlc.compute_corrected_volume(op.volume) if hlc is not None else op.volume + for op, hlc in zip(ops, hamilton_liquid_classes)] - well_bottoms = [op.resource.get_absolute_location().z + \ - (op.offset.z if op.offset is not None else 0) for op in ops] - liquid_surfaces_no_lld = [wb + (op.liquid_height or 1) + well_bottoms = [op.resource.get_absolute_location().z + op.offset.z + \ + op.resource.material_z_thickness for op in ops] + liquid_surfaces_no_lld = liquid_surfaces_no_lld or [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] - lld_search_heights = [wb + op.resource.get_size_z() + \ - (2.7 if isinstance(op.resource, Well) else 5) #? - for wb, op in zip(well_bottoms, ops)] - - aspiration_volumes = [int(op.volume * 10) for op in ops] - lld_search_height = [int(sh * 10) for sh in lld_search_heights] + if lld_search_height is None: + lld_search_height = [ + (wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5)) # ? + for wb, op in zip(well_bottoms, ops) + ] + else: + lld_search_height = [(wb + sh) for wb, sh in zip(well_bottoms, lld_search_height)] clot_detection_height = _fill_in_defaults(clot_detection_height, - default=[int(hlc.aspiration_clot_retract_height*10) if hlc is not None else 0 + default=[hlc.aspiration_clot_retract_height if hlc is not None else 0 for hlc in hamilton_liquid_classes]) - pull_out_distance_transport_air = _fill_in_defaults(pull_out_distance_transport_air, [100]*n) - second_section_height = _fill_in_defaults(second_section_height, [32]*n) - second_section_ratio = _fill_in_defaults(second_section_ratio, [6180]*n) - minimum_height = \ - _fill_in_defaults(minimum_height, [int((ls-5) * 10) for ls in liquid_surfaces_no_lld]) + pull_out_distance_transport_air = _fill_in_defaults(pull_out_distance_transport_air, [10]*n) + second_section_height = _fill_in_defaults(second_section_height, [3.2]*n) + second_section_ratio = _fill_in_defaults(second_section_ratio, [618.0]*n) + minimum_height = _fill_in_defaults(minimum_height, well_bottoms) # TODO: I think minimum height should be the minimum height of the well immersion_depth = _fill_in_defaults(immersion_depth, [0]*n) immersion_depth_direction = _fill_in_defaults(immersion_depth_direction, [0]*n) @@ -1561,13 +1608,12 @@ async def aspirate( flow_rates = [ op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100) for op, hlc in zip(ops, hamilton_liquid_classes)] - aspiration_speed = [int(fr * 10) for fr in flow_rates] transport_air_volume = _fill_in_defaults(transport_air_volume, - default=[int(hlc.aspiration_air_transport_volume*10) if hlc is not None else 0 + default=[hlc.aspiration_air_transport_volume if hlc is not None else 0 for hlc in hamilton_liquid_classes]) - blow_out_air_volumes = [int((op.blow_out_air_volume or + blow_out_air_volumes = [(op.blow_out_air_volume or (hlc.aspiration_blow_out_volume - if hlc is not None else 0)*10)) + if hlc is not None else 0)) for op, hlc in zip(ops, hamilton_liquid_classes)] pre_wetting_volume = _fill_in_defaults(pre_wetting_volume, [0]*n) lld_mode = _fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF]*n) @@ -1578,31 +1624,31 @@ async def aspirate( detection_height_difference_for_dual_lld = \ _fill_in_defaults(detection_height_difference_for_dual_lld, [0]*n) swap_speed = _fill_in_defaults(swap_speed, - default=[int(hlc.aspiration_swap_speed*10) if hlc is not None else 100 + default=[hlc.aspiration_swap_speed if hlc is not None else 100 for hlc in hamilton_liquid_classes]) settling_time = _fill_in_defaults(settling_time, - default=[int(hlc.aspiration_settling_time*10) if hlc is not None else 0 + default=[hlc.aspiration_settling_time if hlc is not None else 0 for hlc in hamilton_liquid_classes]) - homogenization_volume = _fill_in_defaults(homogenization_volume, [0]*n) - homogenization_cycles = _fill_in_defaults(homogenization_cycles, [0]*n) - homogenization_position_from_liquid_surface = \ - _fill_in_defaults(homogenization_position_from_liquid_surface, [0]*n) - homogenization_speed = _fill_in_defaults(homogenization_speed, - default=[int(hlc.aspiration_mix_flow_rate*10) if hlc is not None else 500 + mix_volume = _fill_in_defaults(mix_volume, [0]*n) + mix_cycles = _fill_in_defaults(mix_cycles, [0]*n) + mix_position_from_liquid_surface = \ + _fill_in_defaults(mix_position_from_liquid_surface, [0]*n) + mix_speed = _fill_in_defaults(mix_speed, + default=[hlc.aspiration_mix_flow_rate if hlc is not None else 50.0 for hlc in hamilton_liquid_classes]) - homogenization_surface_following_distance = \ - _fill_in_defaults(homogenization_surface_following_distance, [0]*n) + mix_surface_following_distance = \ + _fill_in_defaults(mix_surface_following_distance, [0]*n) limit_curve_index = _fill_in_defaults(limit_curve_index, [0]*n) use_2nd_section_aspiration = _fill_in_defaults(use_2nd_section_aspiration, [False]*n) retract_height_over_2nd_section_to_empty_tip = \ _fill_in_defaults(retract_height_over_2nd_section_to_empty_tip, [0]*n) dispensation_speed_during_emptying_tip = \ - _fill_in_defaults(dispensation_speed_during_emptying_tip, [500]*n) + _fill_in_defaults(dispensation_speed_during_emptying_tip, [50.0]*n) dosing_drive_speed_during_2nd_section_search = \ - _fill_in_defaults(dosing_drive_speed_during_2nd_section_search, [500]*n) + _fill_in_defaults(dosing_drive_speed_during_2nd_section_search, [50.0]*n) z_drive_speed_during_2nd_section_search = \ - _fill_in_defaults(z_drive_speed_during_2nd_section_search, [300]*n) + _fill_in_defaults(z_drive_speed_during_2nd_section_search, [30.0]*n) cup_upper_edge = _fill_in_defaults(cup_upper_edge, [0]*n) ratio_liquid_rise_to_tip_deep_in = _fill_in_defaults(ratio_liquid_rise_to_tip_deep_in, [0]*n) immersion_depth_2nd_section = _fill_in_defaults(immersion_depth_2nd_section, [0]*n) @@ -1614,47 +1660,55 @@ async def aspirate( x_positions=x_positions, y_positions=y_positions, - aspiration_volumes=aspiration_volumes, - lld_search_height=lld_search_height, - clot_detection_height=clot_detection_height, - liquid_surface_no_lld=[int(ls * 10) for ls in liquid_surfaces_no_lld], - pull_out_distance_transport_air=pull_out_distance_transport_air, - second_section_height=second_section_height, - second_section_ratio=second_section_ratio, - minimum_height=minimum_height, - immersion_depth=immersion_depth, + aspiration_volumes=[round(vol * 10) for vol in volumes], + lld_search_height=[round(lsh * 10) for lsh in lld_search_height], + clot_detection_height=[round(cd * 10) for cd in clot_detection_height], + liquid_surface_no_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], + pull_out_distance_transport_air=[round(po * 10) for po in pull_out_distance_transport_air], + second_section_height=[round(sh * 10) for sh in second_section_height], + second_section_ratio=[round(sr * 10) for sr in second_section_ratio], + minimum_height=[round(mh * 10) for mh in minimum_height], + immersion_depth=[round(id_ * 10) for id_ in immersion_depth], immersion_depth_direction=immersion_depth_direction, - surface_following_distance=surface_following_distance, - aspiration_speed=aspiration_speed, - transport_air_volume=transport_air_volume, - blow_out_air_volume=blow_out_air_volumes, - pre_wetting_volume=pre_wetting_volume, + surface_following_distance=[round(sfd * 10) for sfd in surface_following_distance], + aspiration_speed=[round(fr * 10) for fr in flow_rates], + transport_air_volume=[round(tav * 10) for tav in transport_air_volume], + blow_out_air_volume=[round(boa * 10) for boa in blow_out_air_volumes], + pre_wetting_volume=[round(pwv * 10) for pwv in pre_wetting_volume], lld_mode=[mode.value for mode in lld_mode], gamma_lld_sensitivity=gamma_lld_sensitivity, dp_lld_sensitivity=dp_lld_sensitivity, - aspirate_position_above_z_touch_off=aspirate_position_above_z_touch_off, - detection_height_difference_for_dual_lld=detection_height_difference_for_dual_lld, - swap_speed=swap_speed, - settling_time=settling_time, - homogenization_volume=homogenization_volume, - homogenization_cycles=homogenization_cycles, - homogenization_position_from_liquid_surface=homogenization_position_from_liquid_surface, - homogenization_speed=homogenization_speed, - homogenization_surface_following_distance=homogenization_surface_following_distance, + aspirate_position_above_z_touch_off=[round(ap * 10) + for ap in aspirate_position_above_z_touch_off], + detection_height_difference_for_dual_lld=[round(dh * 10) + for dh in detection_height_difference_for_dual_lld], + swap_speed=[round(ss * 10) for ss in swap_speed], + settling_time=[round(st * 10) for st in settling_time], + mix_volume=[round(hv * 10) for hv in mix_volume], + mix_cycles=mix_cycles, + mix_position_from_liquid_surface=[round(hp * 10) + for hp in mix_position_from_liquid_surface], + mix_speed=[round(hs * 10) for hs in mix_speed], + mix_surface_following_distance=[round(hsd * 10) + for hsd in mix_surface_following_distance], limit_curve_index=limit_curve_index, use_2nd_section_aspiration=use_2nd_section_aspiration, - retract_height_over_2nd_section_to_empty_tip=retract_height_over_2nd_section_to_empty_tip, - dispensation_speed_during_emptying_tip=dispensation_speed_during_emptying_tip, - dosing_drive_speed_during_2nd_section_search=dosing_drive_speed_during_2nd_section_search, - z_drive_speed_during_2nd_section_search=z_drive_speed_during_2nd_section_search, - cup_upper_edge=cup_upper_edge, + retract_height_over_2nd_section_to_empty_tip=[round(rh * 10) + for rh in retract_height_over_2nd_section_to_empty_tip], + dispensation_speed_during_emptying_tip=[round(ds * 10) + for ds in dispensation_speed_during_emptying_tip], + dosing_drive_speed_during_2nd_section_search=[round(ds * 10) + for ds in dosing_drive_speed_during_2nd_section_search], + z_drive_speed_during_2nd_section_search=[round(zs * 10) + for zs in z_drive_speed_during_2nd_section_search], + cup_upper_edge=[round(cue * 10) for cue in cup_upper_edge], ratio_liquid_rise_to_tip_deep_in=ratio_liquid_rise_to_tip_deep_in, - immersion_depth_2nd_section=immersion_depth_2nd_section, + immersion_depth_2nd_section=[round(id_*10) for id_ in immersion_depth_2nd_section], - minimum_traverse_height_at_beginning_of_a_command=\ - minimum_traverse_height_at_beginning_of_a_command or int(self._traversal_height * 10), - min_z_endpos=min_z_endpos or int(self._traversal_height * 10), + minimum_traverse_height_at_beginning_of_a_command= + round((minimum_traverse_height_at_beginning_of_a_command or self._traversal_height) * 10), + min_z_endpos=round((min_z_endpos or self._traversal_height) * 10), ) except STARFirmwareError as e: if plr_e := convert_star_firmware_error_to_plr_error(e): @@ -1666,33 +1720,35 @@ async def dispense( ops: List[Dispense], use_channels: List[int], + lld_search_height: Optional[List[float]] = None, + liquid_surface_no_lld: Optional[List[float]] = None, dispensing_mode: Optional[List[int]] = None, - pull_out_distance_transport_air: Optional[List[int]] = None, - second_section_height: Optional[List[int]] = None, - second_section_ratio: Optional[List[int]] = None, - minimum_height: Optional[List[int]] = None, - immersion_depth: Optional[List[int]] = None, + pull_out_distance_transport_air: Optional[List[float]] = None, + second_section_height: Optional[List[float]] = None, + second_section_ratio: Optional[List[float]] = None, + minimum_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, immersion_depth_direction: Optional[List[int]] = None, - surface_following_distance: Optional[List[int]] = None, - cut_off_speed: Optional[List[int]] = None, - stop_back_volume: Optional[List[int]] = None, - transport_air_volume: Optional[List[int]] = None, + surface_following_distance: Optional[List[float]] = None, + cut_off_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, lld_mode: Optional[List[LLDMode]] = None, - dispense_position_above_z_touch_off: Optional[List[int]] = None, + dispense_position_above_z_touch_off: Optional[List[float]] = None, gamma_lld_sensitivity: Optional[List[int]] = None, dp_lld_sensitivity: Optional[List[int]] = None, - swap_speed: Optional[List[int]] = None, - settling_time: Optional[List[int]] = None, - mix_volume: Optional[List[int]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_volume: Optional[List[float]] = None, mix_cycles: Optional[List[int]] = None, - mix_position_from_liquid_surface: Optional[List[int]] = None, - mix_speed: Optional[List[int]] = None, - mix_surface_following_distance: Optional[List[int]] = None, + mix_position_from_liquid_surface: Optional[List[float]] = None, + mix_speed: Optional[List[float]] = None, + mix_surface_following_distance: Optional[List[float]] = None, limit_curve_index: Optional[List[int]] = None, minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, - min_z_endpos: Optional[int] = None, - side_touch_off_distance: int = 0, + min_z_endpos: Optional[float] = None, + side_touch_off_distance: float = 0, hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None, jet: Optional[List[bool]] = None, @@ -1705,15 +1761,12 @@ async def dispense( the dispenses. For all list parameters, the length of the list must be equal to the number of operations. - .. warning:: The parameters in this method, with the exception of `ops` and `use_channels`, - expect units of tenths of 'millimeters' (i.e. 10 = 1 mm), or tenths of 'microliters' (i.e. 10 - = 1 ul), or tenths of seconds. Speeds are in 0.1ul/s. This is a deviation from the rest of the - API, which uses SI units. This is because the Hamilton API uses these units. - Args: ops: The dispense operations to perform. use_channels: The channels to use for the dispense operations. dispensing_mode: The dispensing mode to use for each operation. + lld_search_height: The height to start searching for the liquid level when using LLD. + liquid_surface_no_lld: Liquid surface at function without LLD. pull_out_distance_transport_air: The distance to pull out the tip for aspirating transport air if LLD is disabled. second_section_height: Unknown. @@ -1731,14 +1784,14 @@ async def dispense( position. gamma_lld_sensitivity: The gamma LLD sensitivity. (1 = high, 4 = low) dp_lld_sensitivity: The dp LLD sensitivity. (1 = high, 4 = low) - swap_speed: The homogenization speed. + swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600. Default 100. settling_time: The settling time. - mix_volume: The volume to use for homogenization. - mix_cycles: The number of homogenization cycles. + mix_volume: The volume to use for mix. + mix_cycles: The number of mix cycles. mix_position_from_liquid_surface: The height to move above the liquid surface for - homogenization. - mix_speed: The homogenization speed. - mix_surface_following_distance: The distance to follow the liquid surface for homogenization. + mix. + mix_speed: The mix speed. + mix_surface_following_distance: The distance to follow the liquid surface for mix. limit_curve_index: The limit curve to use for the dispense. minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before starting a dispense. @@ -1789,43 +1842,45 @@ async def dispense( )) # correct volumes using the liquid class - for op, hlc in zip(ops, hamilton_liquid_classes): - op.volume = hlc.compute_corrected_volume(op.volume) if hlc is not None else op.volume - - well_bottoms = [op.resource.get_absolute_location().z + \ - (op.offset.z if op.offset is not None else 0) for op in ops] - liquid_surfaces_no_lld = [ls + (op.liquid_height or 1) for ls, op in zip(well_bottoms, ops)] - lld_search_heights = [wb + op.resource.get_size_z() + \ - (2.7 if isinstance(op.resource, Well) else 5) #? - for wb, op in zip(well_bottoms, ops)] + volumes = [hlc.compute_corrected_volume(op.volume) if hlc is not None else op.volume + for op, hlc in zip(ops, hamilton_liquid_classes)] + + well_bottoms = [op.resource.get_absolute_location().z + op.offset.z + \ + op.resource.material_z_thickness for op in ops] + liquid_surfaces_no_lld = liquid_surface_no_lld or \ + [ls + (op.liquid_height or 0) for ls, op in zip(well_bottoms, ops)] + if lld_search_height is None: + lld_search_height = [ + (wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5)) #? + for wb, op in zip(well_bottoms, ops) + ] + else: + lld_search_height = [wb + sh for wb, sh in zip(well_bottoms, lld_search_height)] dispensing_modes = dispensing_mode or \ [_dispensing_mode_for_op(empty=empty[i], jet=jet[i], blow_out=blow_out[i]) for i in range(len(ops))] - dispense_volumes = [int(op.volume*10) for op in ops] - pull_out_distance_transport_air = _fill_in_defaults(pull_out_distance_transport_air, [100]*n) - second_section_height = _fill_in_defaults(second_section_height, [32]*n) - second_section_ratio = _fill_in_defaults(second_section_ratio, [6180]*n) - minimum_height = _fill_in_defaults(minimum_height, - default=[int((ls+5) * 10) for ls in liquid_surfaces_no_lld]) + pull_out_distance_transport_air = _fill_in_defaults(pull_out_distance_transport_air, [10.0]*n) + second_section_height = _fill_in_defaults(second_section_height, [3.2]*n) + second_section_ratio = _fill_in_defaults(second_section_ratio, [618.0]*n) + minimum_height = _fill_in_defaults(minimum_height, well_bottoms) immersion_depth = _fill_in_defaults(immersion_depth, [0]*n) immersion_depth_direction = _fill_in_defaults(immersion_depth_direction, [0]*n) surface_following_distance = _fill_in_defaults(surface_following_distance, [0]*n) flow_rates = [ op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120) for op, hlc in zip(ops, hamilton_liquid_classes)] - dispense_speed = [int(fr*10) for fr in flow_rates] - cut_off_speed = _fill_in_defaults(cut_off_speed, [50]*n) + cut_off_speed = _fill_in_defaults(cut_off_speed, [5.0]*n) stop_back_volume = _fill_in_defaults(stop_back_volume, - default=[int(hlc.dispense_stop_back_volume*10) if hlc is not None else 0 + default=[hlc.dispense_stop_back_volume if hlc is not None else 0 for hlc in hamilton_liquid_classes]) transport_air_volume = _fill_in_defaults(transport_air_volume, - default=[int(hlc.dispense_air_transport_volume*10) if hlc is not None else 0 + default=[hlc.dispense_air_transport_volume if hlc is not None else 0 for hlc in hamilton_liquid_classes]) - blow_out_air_volumes = [int((op.blow_out_air_volume or - (hlc.aspiration_blow_out_volume - if hlc is not None else 0)*10)) + blow_out_air_volumes = [(op.blow_out_air_volume or + (hlc.dispense_blow_out_volume + if hlc is not None else 0)) for op, hlc in zip(ops, hamilton_liquid_classes)] lld_mode = _fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF]*n) dispense_position_above_z_touch_off = _fill_in_defaults(dispense_position_above_z_touch_off, @@ -1833,16 +1888,16 @@ async def dispense( gamma_lld_sensitivity = _fill_in_defaults(gamma_lld_sensitivity, [1]*n) dp_lld_sensitivity = _fill_in_defaults(dp_lld_sensitivity, [1]*n) swap_speed = _fill_in_defaults(swap_speed, - default=[int(hlc.dispense_swap_speed*10) if hlc is not None else 100 + default=[hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hamilton_liquid_classes]) settling_time = _fill_in_defaults(settling_time, - default=[int(hlc.dispense_settling_time*10) if hlc is not None else 0 + default=[hlc.dispense_settling_time if hlc is not None else 0 for hlc in hamilton_liquid_classes]) mix_volume = _fill_in_defaults(mix_volume, [0]*n) mix_cycles = _fill_in_defaults(mix_cycles, [0]*n) mix_position_from_liquid_surface = _fill_in_defaults(mix_position_from_liquid_surface, [0]*n) mix_speed = _fill_in_defaults(mix_speed, - default=[int(hlc.dispense_mix_flow_rate*10) if hlc is not None else 500 + default=[hlc.dispense_mix_flow_rate if hlc is not None else 50.0 for hlc in hamilton_liquid_classes]) mix_surface_following_distance = _fill_in_defaults(mix_surface_following_distance, [0]*n) limit_curve_index = _fill_in_defaults(limit_curve_index, [0]*n) @@ -1854,37 +1909,38 @@ async def dispense( y_positions=y_positions, dispensing_mode=dispensing_modes, - dispense_volumes=dispense_volumes, - lld_search_height=[int(sh*10) for sh in lld_search_heights], - liquid_surface_no_lld=[int(ls*10) for ls in liquid_surfaces_no_lld], - pull_out_distance_transport_air=pull_out_distance_transport_air, - second_section_height=second_section_height, - second_section_ratio=second_section_ratio, - minimum_height=minimum_height, - immersion_depth=immersion_depth, + dispense_volumes=[round(vol*10) for vol in volumes], + lld_search_height=[round(lsh*10) for lsh in lld_search_height], + liquid_surface_no_lld=[round(ls*10) for ls in liquid_surfaces_no_lld], + pull_out_distance_transport_air=[round(po*10) for po in pull_out_distance_transport_air], + second_section_height=[round(sh*10) for sh in second_section_height], + second_section_ratio=[round(sr*10) for sr in second_section_ratio], + minimum_height=[round(mh*10) for mh in minimum_height], + immersion_depth=[round(id_*10) for id_ in immersion_depth], # [0, 0] immersion_depth_direction=immersion_depth_direction, - surface_following_distance=surface_following_distance, - dispense_speed=dispense_speed, - cut_off_speed=cut_off_speed, - stop_back_volume=stop_back_volume, - transport_air_volume=transport_air_volume, - blow_out_air_volume=blow_out_air_volumes, + surface_following_distance=[round(sfd*10) for sfd in surface_following_distance], + dispense_speed=[round(fr*10) for fr in flow_rates], + cut_off_speed=[round(cs*10) for cs in cut_off_speed], + stop_back_volume=[round(sbv*10) for sbv in stop_back_volume], + transport_air_volume=[round(tav*10) for tav in transport_air_volume], + blow_out_air_volume=[round(boa*10) for boa in blow_out_air_volumes], lld_mode=[mode.value for mode in lld_mode], - dispense_position_above_z_touch_off=dispense_position_above_z_touch_off, + dispense_position_above_z_touch_off=[round(dp*10) + for dp in dispense_position_above_z_touch_off], gamma_lld_sensitivity=gamma_lld_sensitivity, dp_lld_sensitivity=dp_lld_sensitivity, - swap_speed=swap_speed, - settling_time=settling_time, - mix_volume=mix_volume, + swap_speed=[round(ss*10) for ss in swap_speed], + settling_time=[round(st*10) for st in settling_time], + mix_volume=[round(mv*10) for mv in mix_volume], mix_cycles=mix_cycles, - mix_position_from_liquid_surface=mix_position_from_liquid_surface, - mix_speed=mix_speed, - mix_surface_following_distance=mix_surface_following_distance, + mix_position_from_liquid_surface=[round(mp*10) for mp in mix_position_from_liquid_surface], + mix_speed=[round(ms*10) for ms in mix_speed], + mix_surface_following_distance=[round(msfd*10) for msfd in mix_surface_following_distance], limit_curve_index=limit_curve_index, minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command or int(self._traversal_height * 10), - min_z_endpos=min_z_endpos or int(self._traversal_height * 10), + round((minimum_traverse_height_at_beginning_of_a_command or self._traversal_height) * 10), + min_z_endpos=round((min_z_endpos or self._traversal_height) * 10), side_touch_off_distance=side_touch_off_distance, ) except STARFirmwareError as e: @@ -1898,9 +1954,9 @@ async def pick_up_tips96( self, pickup: PickupTipRack, tip_pickup_method: int = 0, - z_deposit_position: int = 2164, - minimum_height_command_end: Optional[int] = None, - minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, + z_deposit_position: float = 216.4, + minimum_height_command_end: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, ): """ Pick up tips using the 96 head. """ assert self.core96_head_installed, "96 head must be installed" @@ -1909,26 +1965,27 @@ async def pick_up_tips96( assert isinstance(tip_a1, HamiltonTip), "Tip type must be HamiltonTip." ttti = await self.get_or_assign_tip_type_index(tip_a1) position = tip_spot_a1.get_absolute_location() + tip_spot_a1.center() + pickup.offset + z_deposit_position += round(pickup.offset.z*10) x_direction = 0 if position.x > 0 else 1 return await self.pick_up_tips_core96( - x_position=abs(int(position.x * 10)), + x_position=abs(round(position.x * 10)), x_direction=x_direction, - y_position=int(position.y * 10), + y_position=round(position.y * 10), tip_type_idx=ttti, tip_pickup_method=tip_pickup_method, - z_deposit_position=z_deposit_position, + z_deposit_position=round(z_deposit_position*10), minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command or int(self._traversal_height * 10), - minimum_height_command_end=minimum_height_command_end or int(self._traversal_height * 10), + round((minimum_traverse_height_at_beginning_of_a_command or self._traversal_height) * 10), + minimum_height_command_end=round((minimum_height_command_end or self._traversal_height) * 10), ) async def drop_tips96( self, drop: DropTipRack, - z_deposit_position: int = 2164, - minimum_height_command_end: Optional[int] = None, - minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, + z_deposit_position: float = 216.4, + minimum_height_command_end: Optional[float] = None, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, ): """ Drop tips from the 96 head. """ assert self.core96_head_installed, "96 head must be installed" @@ -1940,55 +1997,50 @@ async def drop_tips96( x_direction = 0 if position.x > 0 else 1 return await self.discard_tips_core96( - x_position=abs(int(position.x * 10)), + x_position=abs(round(position.x * 10)), x_direction=x_direction, - y_position=int(position.y * 10), - z_deposit_position=z_deposit_position, + y_position=round(position.y * 10), + z_deposit_position=round(z_deposit_position*10), minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command or int(self._traversal_height * 10), - minimum_height_command_end=minimum_height_command_end or int(self._traversal_height * 10), + round((minimum_traverse_height_at_beginning_of_a_command or self._traversal_height) * 10), + minimum_height_command_end=round((minimum_height_command_end or self._traversal_height) * 10), ) async def aspirate96( self, - aspiration: AspirationPlate, + aspiration: Union[AspirationPlate, AspirationContainer], jet: bool = False, blow_out: bool = False, use_lld: bool = False, - liquid_height: float = 1, + liquid_height: float = 0, air_transport_retract_dist: float = 10, hlc: Optional[HamiltonLiquidClass] = None, aspiration_type: int = 0, - minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, - minimal_end_height: Optional[int] = None, - lld_search_height: int = 1999, - maximum_immersion_depth: int = 1869, - tube_2nd_section_height_measured_from_zm: int = 32, - tube_2nd_section_ratio: int = 6180, - immersion_depth: int = 0, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + minimal_end_height: Optional[float] = None, + lld_search_height: float = 199.9, + maximum_immersion_depth: Optional[float] = None, + tube_2nd_section_height_measured_from_zm: float = 3.2, + tube_2nd_section_ratio: float = 618.0, + immersion_depth: float = 0, immersion_depth_direction: int = 0, - liquid_surface_sink_distance_at_the_end_of_aspiration: int = 0, - transport_air_volume: int = 50, - pre_wetting_volume: int = 50, + liquid_surface_sink_distance_at_the_end_of_aspiration: float = 0, + transport_air_volume: float = 5.0, + pre_wetting_volume: float = 5.0, gamma_lld_sensitivity: int = 1, - swap_speed: int = 20, - settling_time: int = 10, - homogenization_volume: int = 0, - homogenization_cycles: int = 0, - homogenization_position_from_liquid_surface: int = 0, - surface_following_distance_during_homogenization: int = 0, - speed_of_homogenization: int = 1200, + swap_speed: float = 2.0, + settling_time: float = 1.0, + mix_volume: float = 0, + mix_cycles: int = 0, + mix_position_from_liquid_surface: float = 0, + surface_following_distance_during_mix: float = 0, + speed_of_mix: float = 120.0, limit_curve_index: int = 0, ): """ Aspirate using the Core96 head. - .. warning:: The parameters in this method, with the exception of `ops` and `use_channels`, - expect units of tenths of 'millimeters' (i.e. 10 = 1 mm), or tenths of 'microliters' (i.e. 10 - = 1 ul), or tenths of seconds. Speeds are in 0.1ul/s. This is a deviation from the rest of the - API, which uses SI units. This is because the Hamilton API uses these units. - Args: aspiration: The aspiration to perform. @@ -2019,25 +2071,29 @@ async def aspirate96( transport_air_volume: The volume of air to aspirate after the liquid. pre_wetting_volume: The volume of liquid to use for pre-wetting. gamma_lld_sensitivity: The sensitivity of the gamma liquid level detection. - swap_speed: unknown. + swap_speed: Swap speed (on leaving liquid) [1mm/s]. Must be between 0.3 and 160. Default 2. settling_time: The time to wait after aspirating. - homogenization_volume: The volume of liquid to aspirate for homogenization. - homogenization_cycles: The number of cycles to perform for homogenization. - homogenization_position_from_liquid_surface: The position of the homogenization from the + mix_volume: The volume of liquid to aspirate for mix. + mix_cycles: The number of cycles to perform for mix. + mix_position_from_liquid_surface: The position of the mix from the liquid surface. - surface_following_distance_during_homogenization: The distance to follow the liquid surface - during homogenization. - speed_of_homogenization: The speed of homogenization. + surface_following_distance_during_mix: The distance to follow the liquid surface + during mix. + speed_of_mix: The speed of mix. limit_curve_index: The index of the limit curve to use. """ assert self.core96_head_installed, "96 head must be installed" # get the first well and tip as representatives - top_left_well = aspiration.wells[0] - position = top_left_well.get_absolute_location() + top_left_well.center() + aspiration.offset + if isinstance(aspiration, AspirationPlate): + top_left_well = aspiration.wells[0] + position = top_left_well.get_absolute_location() + top_left_well.center() + \ + Coordinate(z=top_left_well.material_z_thickness) + aspiration.offset + else: + position = aspiration.container.get_absolute_location(y="b") + aspiration.offset + tip = aspiration.tips[0] - maximum_immersion_depth = int(position.z*10) liquid_height = position.z + liquid_height @@ -2060,26 +2116,21 @@ async def aspirate96( volume = hlc.compute_corrected_volume(aspiration.volume) else: volume = aspiration.volume - aspiration_volumes = int(volume * 10) + # Get better default values from the HLC if available transport_air_volume = transport_air_volume or \ - (int(hlc.aspiration_air_transport_volume*10) if hlc is not None else 0) - blow_out_air_volume = int((aspiration.blow_out_air_volume or \ - (hlc.aspiration_blow_out_volume if hlc is not None else 0))*10) - flow_rate = int(aspiration.flow_rate or \ - (hlc.aspiration_flow_rate if hlc is not None else 250)) * 10 - swap_speed = swap_speed or (int(hlc.aspiration_swap_speed*10) if hlc is not None else 100) + (hlc.aspiration_air_transport_volume if hlc is not None else 0) + blow_out_air_volume = (aspiration.blow_out_air_volume or \ + (hlc.aspiration_blow_out_volume if hlc is not None else 0)) + flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) + swap_speed = swap_speed or (hlc.aspiration_swap_speed if hlc is not None else 100) settling_time = settling_time or \ - (int(hlc.aspiration_settling_time*10) if hlc is not None else 5) - speed_of_homogenization = speed_of_homogenization or \ - (int(hlc.aspiration_mix_flow_rate*10) if hlc is not None else 100) + (hlc.aspiration_settling_time if hlc is not None else 0.5) + speed_of_mix = speed_of_mix or \ + (hlc.aspiration_mix_flow_rate if hlc is not None else 10.0) channel_pattern = [True]*12*8 - liquid_surface_at_function_without_lld = int(liquid_height * 10) - pull_out_distance_to_take_transport_air_in_function_without_lld = \ - int(air_transport_retract_dist * 10) - # Was this ever true? Just copied it over from pyhamilton. Could have something to do with # the liquid classes and whether blow_out mode is enabled. # # Unfortunately, `blow_out_air_volume` does not work correctly, so instead we aspirate air @@ -2094,41 +2145,41 @@ async def aspirate96( # ) return await self.aspirate_core_96( - x_position=int(position.x * 10), + x_position=round(position.x * 10), x_direction=0, - y_positions=int(position.y * 10), + y_positions=round(position.y * 10), aspiration_type=aspiration_type, minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command or int(self._traversal_height * 10), - minimal_end_height=minimal_end_height or int(self._traversal_height * 10), - lld_search_height=lld_search_height, - liquid_surface_at_function_without_lld=liquid_surface_at_function_without_lld, + round((minimum_traverse_height_at_beginning_of_a_command or self._traversal_height) * 10), + minimal_end_height=round((minimal_end_height or self._traversal_height) * 10), + lld_search_height=round(lld_search_height*10), + liquid_surface_at_function_without_lld=round(liquid_height * 10), pull_out_distance_to_take_transport_air_in_function_without_lld= - pull_out_distance_to_take_transport_air_in_function_without_lld, - maximum_immersion_depth=maximum_immersion_depth, - tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm, - tube_2nd_section_ratio=tube_2nd_section_ratio, - immersion_depth=immersion_depth, + round(air_transport_retract_dist * 10), + maximum_immersion_depth=round((maximum_immersion_depth or position.z)*10), + tube_2nd_section_height_measured_from_zm=round(tube_2nd_section_height_measured_from_zm * 10), + tube_2nd_section_ratio=round(tube_2nd_section_ratio * 10), + immersion_depth=round(immersion_depth * 10), immersion_depth_direction=immersion_depth_direction, liquid_surface_sink_distance_at_the_end_of_aspiration= - liquid_surface_sink_distance_at_the_end_of_aspiration, - aspiration_volumes=aspiration_volumes, - aspiration_speed=flow_rate, - transport_air_volume=transport_air_volume, - blow_out_air_volume=blow_out_air_volume, - pre_wetting_volume=pre_wetting_volume, + round(liquid_surface_sink_distance_at_the_end_of_aspiration*10), + aspiration_volumes=round(volume * 10), + aspiration_speed=round(flow_rate * 10), + transport_air_volume=round(transport_air_volume * 10), + blow_out_air_volume=round(blow_out_air_volume * 10), + pre_wetting_volume=round(pre_wetting_volume * 10), lld_mode=int(use_lld), gamma_lld_sensitivity=gamma_lld_sensitivity, - swap_speed=swap_speed, - settling_time=settling_time, - homogenization_volume=homogenization_volume, - homogenization_cycles=homogenization_cycles, - homogenization_position_from_liquid_surface= - homogenization_position_from_liquid_surface, - surface_following_distance_during_homogenization= - surface_following_distance_during_homogenization, - speed_of_homogenization=speed_of_homogenization, + swap_speed=round(swap_speed*10), + settling_time=round(settling_time * 10), + mix_volume=round(mix_volume * 10), + mix_cycles=mix_cycles, + mix_position_from_liquid_surface= + round(mix_position_from_liquid_surface * 10), + surface_following_distance_during_mix= + round(surface_following_distance_during_mix * 10), + speed_of_mix=round(speed_of_mix * 10), channel_pattern=channel_pattern, limit_curve_index=limit_curve_index, tadm_algorithm=False, @@ -2137,45 +2188,40 @@ async def aspirate96( async def dispense96( self, - dispense: DispensePlate, + dispense: Union[DispensePlate, DispenseContainer], jet: bool = False, empty: bool = False, blow_out: bool = False, hlc: Optional[HamiltonLiquidClass] = None, - liquid_height: float = 1, + liquid_height: float = 0, dispense_mode: Optional[int] = None, air_transport_retract_dist=10, use_lld: bool = False, - minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, - minimal_end_height: Optional[int] = None, - lld_search_height: int = 1999, - maximum_immersion_depth: int = 1869, - tube_2nd_section_height_measured_from_zm: int = 32, - tube_2nd_section_ratio: int = 6180, - immersion_depth: int = 0, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + minimal_end_height: Optional[float] = None, + lld_search_height: float = 199.9, + maximum_immersion_depth: Optional[float] = None, + tube_2nd_section_height_measured_from_zm: float = 3.2, + tube_2nd_section_ratio: float = 618.0, + immersion_depth: float = 0, immersion_depth_direction: int = 0, - liquid_surface_sink_distance_at_the_end_of_dispense: int = 0, - transport_air_volume: int = 50, + liquid_surface_sink_distance_at_the_end_of_dispense: float = 0, + transport_air_volume: float = 5.0, gamma_lld_sensitivity: int = 1, - swap_speed: int = 20, - settling_time: int = 0, - mixing_volume: int = 0, + swap_speed: float = 2.0, + settling_time: float = 0, + mixing_volume: float = 0, mixing_cycles: int = 0, - mixing_position_from_liquid_surface: int = 0, - surface_following_distance_during_mixing: int = 0, - speed_of_mixing: int = 1200, + mixing_position_from_liquid_surface: float = 0, + surface_following_distance_during_mixing: float = 0, + speed_of_mixing: float = 120.0, limit_curve_index: int = 0, - cut_off_speed: int = 50, - stop_back_volume: int = 0, + cut_off_speed: float = 5.0, + stop_back_volume: float = 0, ): - """ Aspirate using the Core96 head. - - .. warning:: The parameters in this method, with the exception of `ops` and `use_channels`, - expect units of tenths of 'millimeters' (i.e. 10 = 1 mm), or tenths of 'microliters' (i.e. 10 - = 1 ul), or tenths of seconds. Speeds are in 0.1ul/s. This is a deviation from the rest of the - API, which uses SI units. This is because the Hamilton API uses these units. + """ Dispense using the Core96 head. Args: dispense: The Dispense command to execute. @@ -2200,13 +2246,13 @@ async def dispense96( liquid_surface_sink_distance_at_the_end_of_dispense: Unknown. transport_air_volume: Transport air volume, to dispense before aspiration. gamma_lld_sensitivity: Gamma LLD sensitivity. - swap_speed: Unknown. - settling_time: Settling time, in 0.1 seconds. + swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 0.3 and 160. Default 10. + settling_time: Settling time, in seconds. mixing_volume: Mixing volume, in ul. mixing_cycles: Mixing cycles. mixing_position_from_liquid_surface: Mixing position from liquid surface, in mm. surface_following_distance_during_mixing: Surface following distance during mixing, in mm. - speed_of_mixing: Speed of mixing, in 0.1 ul/s. + speed_of_mixing: Speed of mixing, in ul/s. limit_curve_index: Limit curve index. cut_off_speed: Unknown. stop_back_volume: Unknown. @@ -2215,10 +2261,13 @@ async def dispense96( assert self.core96_head_installed, "96 head must be installed" # get the first well and tip as representatives - top_left_well = dispense.wells[0] - position = top_left_well.get_absolute_location() + top_left_well.center() + dispense.offset + if isinstance(dispense, DispensePlate): + top_left_well = dispense.wells[0] + position = top_left_well.get_absolute_location() + top_left_well.center() + \ + Coordinate(z=top_left_well.material_z_thickness) + dispense.offset + else: + position = dispense.container.get_absolute_location(y="b") + dispense.offset tip = dispense.tips[0] - maximum_immersion_depth = int(position.z*10) liquid_height = position.z + liquid_height @@ -2243,65 +2292,57 @@ async def dispense96( volume = hlc.compute_corrected_volume(dispense.volume) else: volume = dispense.volume - dispense_volumes = int(volume * 10) transport_air_volume = transport_air_volume or \ - (int(hlc.dispense_air_transport_volume*10) if hlc is not None else 0) - blow_out_air_volume = int((dispense.blow_out_air_volume or \ - (hlc.dispense_blow_out_volume if hlc is not None else 0))*10) - flow_rate = int(dispense.flow_rate or \ - (hlc.dispense_flow_rate if hlc is not None else 120)) * 10 - swap_speed = swap_speed or (int(hlc.dispense_swap_speed*10) if hlc is not None else 100) - settling_time = settling_time or \ - (int(hlc.dispense_settling_time*10) if hlc is not None else 5) - speed_of_mixing = speed_of_mixing or \ - (int(hlc.dispense_mix_flow_rate*10) if hlc is not None else 100) - - liquid_surface_at_function_without_lld: int = int(liquid_height*10) - pull_out_distance_to_take_transport_air_in_function_without_lld = air_transport_retract_dist*10 + (hlc.dispense_air_transport_volume if hlc is not None else 0) + blow_out_air_volume = (dispense.blow_out_air_volume or \ + (hlc.dispense_blow_out_volume if hlc is not None else 0)) + flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120) + swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) + settling_time = settling_time or (hlc.dispense_settling_time if hlc is not None else 5) + speed_of_mixing = speed_of_mixing or (hlc.dispense_mix_flow_rate if hlc is not None else 100) channel_pattern = [True]*12*8 ret = await self.dispense_core_96( dispensing_mode=dispense_mode, - x_position=int(position.x * 10), + x_position=round(position.x * 10), x_direction=0, - y_position=int(position.y * 10), + y_position=round(position.y * 10), minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command or int(self._traversal_height * 10), - minimal_end_height=minimal_end_height or int(self._traversal_height * 10), - lld_search_height=lld_search_height, - liquid_surface_at_function_without_lld= - liquid_surface_at_function_without_lld, + round((minimum_traverse_height_at_beginning_of_a_command or self._traversal_height)*10), + minimal_end_height=round((minimal_end_height or self._traversal_height)*10), + lld_search_height=round(lld_search_height*10), + liquid_surface_at_function_without_lld=round(liquid_height*10), pull_out_distance_to_take_transport_air_in_function_without_lld= - pull_out_distance_to_take_transport_air_in_function_without_lld, - maximum_immersion_depth=maximum_immersion_depth, - tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm, - tube_2nd_section_ratio=tube_2nd_section_ratio, - immersion_depth=immersion_depth, + round(air_transport_retract_dist*10), + maximum_immersion_depth=maximum_immersion_depth or round(position.z*10), + tube_2nd_section_height_measured_from_zm=round(tube_2nd_section_height_measured_from_zm*10), + tube_2nd_section_ratio=round(tube_2nd_section_ratio*10), + immersion_depth=round(immersion_depth*10), immersion_depth_direction=immersion_depth_direction, liquid_surface_sink_distance_at_the_end_of_dispense= - liquid_surface_sink_distance_at_the_end_of_dispense, - dispense_volume=dispense_volumes, - dispense_speed=flow_rate, - transport_air_volume=transport_air_volume, - blow_out_air_volume=blow_out_air_volume, + round(liquid_surface_sink_distance_at_the_end_of_dispense*10), + dispense_volume=round(volume*10), + dispense_speed=round(flow_rate*10), + transport_air_volume=round(transport_air_volume*10), + blow_out_air_volume=round(blow_out_air_volume*10), lld_mode=int(use_lld), gamma_lld_sensitivity=gamma_lld_sensitivity, - swap_speed=swap_speed, - settling_time=settling_time, - mixing_volume=mixing_volume, + swap_speed=round(swap_speed*10), + settling_time=round(settling_time*10), + mixing_volume=round(mixing_volume*10), mixing_cycles=mixing_cycles, - mixing_position_from_liquid_surface=mixing_position_from_liquid_surface, - surface_following_distance_during_mixing=surface_following_distance_during_mixing, - speed_of_mixing=speed_of_mixing, + mixing_position_from_liquid_surface=round(mixing_position_from_liquid_surface*10), + surface_following_distance_during_mixing=round(surface_following_distance_during_mixing*10), + speed_of_mixing=round(speed_of_mixing*10), channel_pattern=channel_pattern, limit_curve_index=limit_curve_index, tadm_algorithm=False, recording_mode=0, - cut_off_speed=cut_off_speed, - stop_back_volume=stop_back_volume, + cut_off_speed=round(cut_off_speed*10), + stop_back_volume=round(stop_back_volume*10), ) # Was this ever true? Just copied it over from pyhamilton. Could have something to do with @@ -2325,10 +2366,10 @@ async def iswap_pick_up_resource( grip_direction: GripDirection, pickup_distance_from_top: float, offset: Coordinate = Coordinate.zero(), - minimum_traverse_height_at_beginning_of_a_command: int = 2840, - z_position_at_the_command_end: int = 2840, + minimum_traverse_height_at_beginning_of_a_command: float = 284.0, + z_position_at_the_command_end: float = 284.0, grip_strength: int = 4, - plate_width_tolerance: int = 20, + plate_width_tolerance: float = 2.0, collision_control_level: int = 0, acceleration_index_high_acc: int = 4, acceleration_index_low_acc: int = 1, @@ -2341,21 +2382,21 @@ async def iswap_pick_up_resource( assert self.iswap_installed, "iswap must be installed" # Get center of source plate. Also gripping height and plate width. - center = resource.get_absolute_location() + resource.center() + offset - grip_height = center.z + resource.get_size_z() - pickup_distance_from_top + center = resource.get_absolute_location(x="c", y="c", z="b") + offset + grip_height = center.z + resource.get_absolute_size_z() - pickup_distance_from_top if grip_direction in (GripDirection.FRONT, GripDirection.BACK): - plate_width = resource.get_size_x() + plate_width = resource.get_absolute_size_x() elif grip_direction in (GripDirection.RIGHT, GripDirection.LEFT): - plate_width = resource.get_size_y() + plate_width = resource.get_absolute_size_y() else: raise ValueError("Invalid grip direction") await self.iswap_get_plate( - x_position=int(center.x * 10), + x_position=round(center.x * 10), x_direction=0, - y_position=int(center.y * 10), + y_position=round(center.y * 10), y_direction=0, - z_position=int(grip_height * 10), + z_position=round(grip_height * 10), z_direction=0, grip_direction={ GripDirection.FRONT: 1, @@ -2364,12 +2405,12 @@ async def iswap_pick_up_resource( GripDirection.LEFT: 4, }[grip_direction], minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command, - z_position_at_the_command_end=z_position_at_the_command_end, + round(minimum_traverse_height_at_beginning_of_a_command*10), + z_position_at_the_command_end=round(z_position_at_the_command_end*10), grip_strength=grip_strength, - open_gripper_position=int(plate_width*10) + 30, - plate_width=int(plate_width*10) - 33, - plate_width_tolerance=plate_width_tolerance, + open_gripper_position=round(plate_width*10) + 30, + plate_width=round(plate_width*10) - 33, + plate_width_tolerance=round(plate_width_tolerance*10), collision_control_level=collision_control_level, acceleration_index_high_acc=acceleration_index_high_acc, acceleration_index_low_acc=acceleration_index_low_acc, @@ -2381,7 +2422,7 @@ async def iswap_move_picked_up_resource( location: Coordinate, resource: Resource, grip_direction: GripDirection, - minimum_traverse_height_at_beginning_of_a_command: int = 2840, + minimum_traverse_height_at_beginning_of_a_command: float = 284.0, collision_control_level: int = 1, acceleration_index_high_acc: int = 4, acceleration_index_low_acc: int = 1 @@ -2395,11 +2436,11 @@ async def iswap_move_picked_up_resource( center = location + resource.center() await self.move_plate_to_position( - x_position=int(center.x * 10), + x_position=round(center.x * 10), x_direction=0, - y_position=int(center.y * 10), + y_position=round(center.y * 10), y_direction=0, - z_position=int((location.z + resource.get_size_z() / 2) * 10), + z_position=round((location.z + resource.get_absolute_size_z() / 2) * 10), z_direction=0, grip_direction={ GripDirection.FRONT: 1, @@ -2408,7 +2449,7 @@ async def iswap_move_picked_up_resource( GripDirection.LEFT: 4, }[grip_direction], minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command, + round(minimum_traverse_height_at_beginning_of_a_command*10), collision_control_level=collision_control_level, acceleration_index_high_acc=acceleration_index_high_acc, acceleration_index_low_acc=acceleration_index_low_acc @@ -2418,36 +2459,55 @@ async def iswap_release_picked_up_resource( self, location: Coordinate, resource: Resource, + rotation: int, offset: Coordinate, grip_direction: GripDirection, pickup_distance_from_top: float, - minimum_traverse_height_at_beginning_of_a_command: int = 2840, - z_position_at_the_command_end: int = 2840, + minimum_traverse_height_at_beginning_of_a_command: float = 284.0, + z_position_at_the_command_end: float = 284.0, collision_control_level: int = 0, ): """ After a resource is picked up, release it at the specified location. Low level component of :meth:`move_resource` + + Args: + location: The location to release the resource (bottom front left corner). + resource: The resource to release. + rotation: The rotation of the resource's final orientation wrt the pickup orientation. + offset: offset for location + grip_direction: The direction of the iswap arm on release. + pickup_distance_from_top: How far from the top the resource was picked up. """ assert self.iswap_installed, "iswap must be installed" - # Get center of source plate. Also gripping height and plate width. - center = location + resource.center() + offset - grip_height = center.z + resource.get_size_z() - pickup_distance_from_top - plate_width = resource.get_size_x() + # Get center of source plate in absolute space. + # The computation of the center has to be rotated so that the offset is in absolute space. + center_in_absolute_space = Coordinate(*matrix_vector_multiply_3x3( + resource.rotated(z=rotation).get_absolute_rotation().get_rotation_matrix(), + resource.center().vector() + )) + # This is when the resource is rotated (around its origin), but we also need to translate + # so that the left front bottom corner of the plate is lfb in absolute space, not local. + center_in_absolute_space += get_child_location(resource.rotated(z=rotation)) + + center = location + center_in_absolute_space + offset + grip_height = center.z + resource.get_absolute_size_z() - pickup_distance_from_top + # grip_direction here is the put_direction. We use `rotation` to cancel it out and get the + # original grip direction. Hack. if grip_direction in (GripDirection.FRONT, GripDirection.BACK): - plate_width = resource.get_size_x() + plate_width = resource.rotated(z=rotation).get_absolute_size_x() elif grip_direction in (GripDirection.RIGHT, GripDirection.LEFT): - plate_width = resource.get_size_y() + plate_width = resource.rotated(z=rotation).get_absolute_size_y() else: raise ValueError("Invalid grip direction") await self.iswap_put_plate( - x_position=int(center.x * 10), + x_position=round(center.x * 10), x_direction=0, - y_position=int(center.y * 10), + y_position=round(center.y * 10), y_direction=0, - z_position=int(grip_height * 10), + z_position=round(grip_height * 10), z_direction=0, grip_direction={ GripDirection.FRONT: 1, @@ -2456,12 +2516,147 @@ async def iswap_release_picked_up_resource( GripDirection.LEFT: 4, }[grip_direction], minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command, - z_position_at_the_command_end=z_position_at_the_command_end, - open_gripper_position=int(plate_width*10) + 30, + round(minimum_traverse_height_at_beginning_of_a_command*10), + z_position_at_the_command_end=round(z_position_at_the_command_end*10), + open_gripper_position=round(plate_width*10) + 30, collision_control_level=collision_control_level, ) + async def core_pick_up_resource( + self, + resource: Resource, + pickup_distance_from_top: float, + offset: Coordinate = Coordinate.zero(), + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + minimum_z_position_at_the_command_end: Optional[float] = None, + grip_strength: int = 15, + z_speed: float = 50.0, + y_gripping_speed: float = 5.0, + channel_1: int = 7, + channel_2: int = 8, + ): + """ Pick up resource with CoRe gripper tool + Low level component of :meth:`move_resource` + + Args: + resource: Resource to pick up. + offset: Offset from resource position in mm. + pickup_distance_from_top: Distance from top of resource to pick up. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command [mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 360. + grip_strength: Grip strength (0 = weak, 99 = strong). Must be between 0 and 99. Default 15. + z_speed: Z speed [mm/s]. Must be between 0.4 and 128.7. Default 50.0. + y_gripping_speed: Y gripping speed [mm/s]. Must be between 0 and 370.0. Default 5.0. + channel_1: Channel 1. Must be between 0 and self._num_channels - 1. Default 7. + channel_2: Channel 2. Must be between 1 and self._num_channels. Default 8. + """ + + # Get center of source plate. Also gripping height and plate width. + center = resource.get_absolute_location(x="c", y="c", z="b") + offset + grip_height = center.z + resource.get_absolute_size_z() - pickup_distance_from_top + grip_width = resource.get_absolute_size_y() #grip width is y size of resource + + if self.core_parked: + await self.get_core(p1=channel_1, p2=channel_2) + + await self.core_get_plate( + x_position=round(center.x * 10), + x_direction=0, + y_position=round(center.y * 10), + y_gripping_speed=round(y_gripping_speed*10), + z_position=round(grip_height * 10), + z_speed=round(z_speed*10), + open_gripper_position=round(grip_width*10) + 30, + plate_width=round(grip_width*10) - 30, + grip_strength=grip_strength, + minimum_traverse_height_at_beginning_of_a_command= + round((minimum_traverse_height_at_beginning_of_a_command or self._traversal_height)*10), + minimum_z_position_at_the_command_end= + round((minimum_z_position_at_the_command_end or self._traversal_height)*10), + ) + + async def core_move_picked_up_resource( + self, + location: Coordinate, + resource: Resource, + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + acceleration_index: int = 4, + z_speed: float = 50.0, + ): + """ After a ressource is picked up, move it to a new location but don't release it yet. + Low level component of :meth:`move_resource` + + Args: + location: Location to move to. + resource: Resource to move. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command [0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 3600. Default 3600. + acceleration_index: Acceleration index (0 = 0.1 mm/s2, 1 = 0.2 mm/s2, 2 = 0.5 mm/s2, + 3 = 1.0 mm/s2, 4 = 2.0 mm/s2, 5 = 5.0 mm/s2, 6 = 10.0 mm/s2, 7 = 20.0 mm/s2). Must be + between 0 and 7. Default 4. + z_speed: Z speed [0.1mm/s]. Must be between 3 and 1600. Default 500. + """ + + center = location + resource.center() + + await self.core_move_plate_to_position( + x_position=round(center.x * 10), + x_direction=0, + x_acceleration_index=acceleration_index, + y_position=round(center.y * 10), + z_position=round(center.z * 10), + z_speed=round(z_speed*10), + minimum_traverse_height_at_beginning_of_a_command= + round((minimum_traverse_height_at_beginning_of_a_command or self._traversal_height)*10), + ) + + async def core_release_picked_up_resource( + self, + location: Coordinate, + resource: Resource, + pickup_distance_from_top: float, + offset: Coordinate = Coordinate.zero(), + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, + z_position_at_the_command_end: Optional[float] = None, + return_tool: bool = True + ): + """ Place resource with CoRe gripper tool + Low level component of :meth:`move_resource` + + Args: + resource: Location to place. + pickup_distance_from_top: Distance from top of resource to place. + offset: Offset from resource position in mm. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a + command [mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 360.0. + z_position_at_the_command_end: Minimum z-Position at end of a command [mm] (refers to all + channels independent of tip pattern parameter 'tm'). Must be between 0 and 360.0 + return_tool: Return tool to wasteblock mount after placing. Default True. + """ + + # Get center of destination location. Also gripping height and plate width. + center = location + resource.center() + offset + grip_height = center.z + resource.get_absolute_size_z() - pickup_distance_from_top + grip_width = resource.get_absolute_size_y() + + await self.core_put_plate( + x_position=round(center.x * 10), + x_direction=0, + y_position=round(center.y * 10), + z_position=round(grip_height * 10), + z_press_on_distance=0, + z_speed=500, + open_gripper_position=round(grip_width*10) + 30, + minimum_traverse_height_at_beginning_of_a_command= + round((minimum_traverse_height_at_beginning_of_a_command or self._traversal_height) * 10), + z_position_at_the_command_end= + round((z_position_at_the_command_end or self._traversal_height)*10), + return_tool=return_tool + ) + async def move_resource( self, move: Move, @@ -2487,31 +2682,30 @@ async def move_resource( if not use_arm in {"iswap", "core"}: raise ValueError(f"use_arm must be either 'iswap' or 'core', not {use_arm}") - minimum_traverse_height = 284.0 - if use_arm == "iswap": await self.iswap_pick_up_resource( resource=move.resource, grip_direction=move.get_direction, pickup_distance_from_top=move.pickup_distance_from_top, offset=move.resource_offset, - minimum_traverse_height_at_beginning_of_a_command=int(self._traversal_height * 10), - z_position_at_the_command_end=int(self._traversal_height * 10), + minimum_traverse_height_at_beginning_of_a_command=self._traversal_height, + z_position_at_the_command_end=self._traversal_height, ) else: await self.core_pick_up_resource( resource=move.resource, pickup_distance_from_top=move.pickup_distance_from_top, offset=move.resource_offset, - minimum_traverse_height_at_beginning_of_a_command=int(self._traversal_height * 10), - minimum_z_position_at_the_command_end=int(self._traversal_height * 10), + minimum_traverse_height_at_beginning_of_a_command=self._traversal_height, + minimum_z_position_at_the_command_end=self._traversal_height, channel_1=channel_1, channel_2=channel_2, grip_strength=core_grip_strength, ) previous_location = move.resource.get_absolute_location() + move.resource_offset - previous_location.z = minimum_traverse_height - move.resource.get_size_z() / 2 + minimum_traverse_height = 284.0 + previous_location.z = minimum_traverse_height - move.resource.get_absolute_size_z() / 2 for location in move.intermediate_locations: if use_arm == "iswap": @@ -2519,7 +2713,7 @@ async def move_resource( location=location, resource=move.resource, grip_direction=move.get_direction, - minimum_traverse_height_at_beginning_of_a_command=int(self._traversal_height * 10), + minimum_traverse_height_at_beginning_of_a_command=self._traversal_height, # int(previous_location.z + move.resource.get_size_z() / 2) * 10, # "minimum" is a scam. collision_control_level=1, acceleration_index_high_acc=4, @@ -2528,25 +2722,23 @@ async def move_resource( await self.core_move_picked_up_resource( location=location, resource=move.resource, - minimum_traverse_height_at_beginning_of_a_command=int(self._traversal_height * 10), + minimum_traverse_height_at_beginning_of_a_command=self._traversal_height, # int(previous_location.z + move.resource.get_size_z() / 2) * 10, acceleration_index=4 ) previous_location = location - if move.rotation != 0: - move.resource.rotate(move.rotation) - if use_arm == "iswap": await self.iswap_release_picked_up_resource( location=move.destination, resource=move.resource, + rotation=move.rotation, offset=move.destination_offset, grip_direction=move.put_direction, pickup_distance_from_top=move.pickup_distance_from_top, - minimum_traverse_height_at_beginning_of_a_command=int(self._traversal_height * 10), + minimum_traverse_height_at_beginning_of_a_command=self._traversal_height, # int(previous_location.z + move.resource.get_size_z() / 2) * 10, # "minimum" is a scam. - z_position_at_the_command_end=int(self._traversal_height * 10), + z_position_at_the_command_end=self._traversal_height, ) else: await self.core_release_picked_up_resource( @@ -2554,8 +2746,8 @@ async def move_resource( resource=move.resource, offset=move.destination_offset, pickup_distance_from_top=move.pickup_distance_from_top, - minimum_traverse_height_at_beginning_of_a_command=int(self._traversal_height * 10), - z_position_at_the_command_end=int(self._traversal_height * 10), + minimum_traverse_height_at_beginning_of_a_command=self._traversal_height, + z_position_at_the_command_end=self._traversal_height, # int(previous_location.z + move.resource.get_size_z() / 2) * 10, return_tool=return_core_gripper ) @@ -2567,17 +2759,115 @@ async def prepare_for_manual_channel_operation(self, channel: int): async def move_channel_x(self, channel: int, x: float): # pylint: disable=unused-argument """ Move a channel in the x direction. """ - await self.position_left_x_arm_(int(x * 10)) + await self.position_left_x_arm_(round(x * 10)) async def move_channel_y(self, channel: int, y: float): """ Move a channel in the y direction. """ await self.position_single_pipetting_channel_in_y_direction( - pipetting_channel_index=channel + 1, y_position=int(y * 10)) + pipetting_channel_index=channel + 1, y_position=round(y * 10)) async def move_channel_z(self, channel: int, z: float): """ Move a channel in the z direction. """ await self.position_single_pipetting_channel_in_z_direction( - pipetting_channel_index=channel + 1, z_position=int(z*10)) + pipetting_channel_index=channel + 1, z_position=round(z*10)) + + async def core_check_resource_exists_at_location_center( + self, + location: Coordinate, + resource: Resource, + gripper_y_margin: float = 0.5, + offset: Coordinate = Coordinate.zero(), + minimum_traverse_height_at_beginning_of_a_command: float = 275.0, + z_position_at_the_command_end: float = 275.0, + enable_recovery: bool = True, + audio_feedback: bool = True + ) -> bool: + """ Check existence of resource with CoRe gripper tool + a "Get plate using CO-RE gripper" + error handling + Which channels are used for resource check is dependent on which channels have been used for + `STAR.get_core(p1: int, p2: int)` which is a prerequisite for this check function. + + Args: + location: Location to check for resource + resource: Resource to check for. + gripper_y_margin = Distance between the front / back wall of the resource + and the grippers during "bumping" / checking + offset: Offset from resource position in mm. + minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of + a command [mm] (refers to all channels independent of tip pattern parameter 'tm'). + Must be between 0 and 360.0. + z_position_at_the_command_end: Minimum z-Position at end of a command [mm] (refers to + all channels independent of tip pattern parameter 'tm'). Must be between 0 and 360.0. + enable_recovery: if True will ask for user input if resource was not found + audio_feedback: enable controlling computer to emit different sounds when + finding/not finding the resource + + Returns: + True if resource was found, False if resource was not found + """ + + center = location + resource.centers()[0] + offset + y_width_to_gripper_bump = resource.get_absolute_size_y() - gripper_y_margin*2 + assert 9 <= y_width_to_gripper_bump <= round(resource.get_absolute_size_y()), \ + f"width between channels must be between 9 and {resource.get_absolute_size_y()} mm" \ + " (i.e. the minimal distance between channels and the max y size of the resource" + + # Check if CoRe gripper currently in use + cores_used = not self._core_parked + if not cores_used: + raise ValueError("CoRe grippers not yet picked up.") + + # Enable recovery of failed checks + resource_found = False + try_counter = 0 + while not resource_found: + try: + await self.core_get_plate( + x_position=round(center.x * 10), + y_position=round(center.y * 10), + z_position=round(center.z * 10), + open_gripper_position=round(y_width_to_gripper_bump * 10), + plate_width=round(y_width_to_gripper_bump * 10), + # Set default values based on VENUS check_plate commands + y_gripping_speed=50, + x_direction=0, + z_speed=600, + grip_strength = 20, + # Enable mods of channel z position for check acceleration + minimum_traverse_height_at_beginning_of_a_command= + round(minimum_traverse_height_at_beginning_of_a_command*10), + minimum_z_position_at_the_command_end=round(z_position_at_the_command_end*10), + ) + except STARFirmwareError as exc: + for module_error in exc.errors.values(): + if module_error.trace_information == 62: + resource_found = True + else: + raise ValueError(f"Unexpected error encountered: {exc}") from exc + else: + if audio_feedback: + audio.play_not_found() + if enable_recovery: + print(f"\nWARNING: Resource '{resource.name}' not found at center" \ + f" location {(center.x, center.y, center.z)} during check no {try_counter}.") + user_prompt = input("Have you checked resource is present?" \ + "\n [ yes ] -> machine will check location again" \ + "\n [ abort ] -> machine will abort run\n Answer:" + ) + if user_prompt == "yes": + try_counter += 1 + elif user_prompt == "abort": + raise ValueError(f"Resource '{resource.name}' not found at center" \ + f" location {(center.x,center.y,center.z)}" \ + " & error not resolved -> aborted resource movement.") + else: + # Resource was not found + return False + + # Resource was found + if audio_feedback: + audio.play_got_item() + return True # ============== Firmware Commands ============== @@ -3591,11 +3881,11 @@ async def aspirate_pip( detection_height_difference_for_dual_lld: List[int] = [0], swap_speed: List[int] = [100], settling_time: List[int] = [5], - homogenization_volume: List[int] = [0], - homogenization_cycles: List[int] = [0], - homogenization_position_from_liquid_surface: List[int] = [250], - homogenization_speed: List[int] = [500], - homogenization_surface_following_distance: List[int] = [0], + mix_volume: List[int] = [0], + mix_cycles: List[int] = [0], + mix_position_from_liquid_surface: List[int] = [250], + mix_speed: List[int] = [500], + mix_surface_following_distance: List[int] = [0], limit_curve_index: List[int] = [0], tadm_algorithm: bool = False, recording_mode: int = 0, @@ -3669,14 +3959,14 @@ async def aspirate_pip( swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600. Default 100. settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. - homogenization_volume: Homogenization volume [0.1ul]. Must be between 0 and 12500. Default 0 - homogenization_cycles: Number of homogenization cycles. Must be between 0 and 99. Default 0. - homogenization_position_from_liquid_surface: Homogenization position in Z- direction from + mix_volume: mix volume [0.1ul]. Must be between 0 and 12500. Default 0 + mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: mix position in Z- direction from liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 900. Default 250. - homogenization_speed: Speed of homogenization [0.1ul/s]. Must be between 4 and 5000. + mix_speed: Speed of mix [0.1ul/s]. Must be between 4 and 5000. Default 500. - homogenization_surface_following_distance: Surface following distance during - homogenization [0.1mm]. Must be between 0 and 3600. Default 0. + mix_surface_following_distance: Surface following distance during + mix [0.1mm]. Must be between 0 and 3600. Default 0. limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. tadm_algorithm: TADM algorithm. Default False. recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must @@ -3743,16 +4033,16 @@ async def aspirate_pip( "detection_height_difference_for_dual_lld must be between 0 and 99" assert all(3 <= x <= 1600 for x in swap_speed), "swap_speed must be between 3 and 1600" assert all(0 <= x <= 99 for x in settling_time), "settling_time must be between 0 and 99" - assert all(0 <= x <= 12500 for x in homogenization_volume), \ - "homogenization_volume must be between 0 and 12500" - assert all(0 <= x <= 99 for x in homogenization_cycles), \ - "homogenization_cycles must be between 0 and 99" - assert all(0 <= x <= 900 for x in homogenization_position_from_liquid_surface), \ - "homogenization_position_from_liquid_surface must be between 0 and 900" - assert all(4 <= x <= 5000 for x in homogenization_speed), \ - "homogenization_speed must be between 4 and 5000" - assert all(0 <= x <= 3600 for x in homogenization_surface_following_distance), \ - "homogenization_surface_following_distance must be between 0 and 3600" + assert all(0 <= x <= 12500 for x in mix_volume), \ + "mix_volume must be between 0 and 12500" + assert all(0 <= x <= 99 for x in mix_cycles), \ + "mix_cycles must be between 0 and 99" + assert all(0 <= x <= 900 for x in mix_position_from_liquid_surface), \ + "mix_position_from_liquid_surface must be between 0 and 900" + assert all(4 <= x <= 5000 for x in mix_speed), \ + "mix_speed must be between 4 and 5000" + assert all(0 <= x <= 3600 for x in mix_surface_following_distance), \ + "mix_surface_following_distance must be between 0 and 3600" assert all(0 <= x <= 999 for x in limit_curve_index), \ "limit_curve_index must be between 0 and 999" assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" @@ -3774,7 +4064,7 @@ async def aspirate_pip( module="C0", command="AS", tip_pattern=tip_pattern, - read_timeout=60, + read_timeout=max(60, self.read_timeout), at=[f"{at:01}" for at in aspiration_type], tm=tip_pattern, xp=[f"{xp:05}" for xp in x_positions], @@ -3803,11 +4093,11 @@ async def aspirate_pip( ld=[f"{ld:02}" for ld in detection_height_difference_for_dual_lld], de=[f"{de:04}" for de in swap_speed], wt=[f"{wt:02}" for wt in settling_time], - mv=[f"{mv:05}" for mv in homogenization_volume], - mc=[f"{mc:02}" for mc in homogenization_cycles], - mp=[f"{mp:03}" for mp in homogenization_position_from_liquid_surface], - ms=[f"{ms:04}" for ms in homogenization_speed], - mh=[f"{mh:04}" for mh in homogenization_surface_following_distance], + mv=[f"{mv:05}" for mv in mix_volume], + mc=[f"{mc:02}" for mc in mix_cycles], + mp=[f"{mp:03}" for mp in mix_position_from_liquid_surface], + ms=[f"{ms:04}" for ms in mix_speed], + mh=[f"{mh:04}" for mh in mix_surface_following_distance], gi=[f"{gi:03}" for gi in limit_curve_index], gj=tadm_algorithm, gk=recording_mode, @@ -3991,7 +4281,7 @@ async def dispense_pip( module="C0", command="DS", tip_pattern=tip_pattern, - read_timeout=60, + read_timeout=max(60, self.read_timeout), dm=[f"{dm:01}" for dm in dispensing_mode], tm=[f"{tm:01}" for tm in tip_pattern], xp=[f"{xp:05}" for xp in x_positions], @@ -4046,18 +4336,29 @@ async def get_core(self, p1: int, p2: int): if not 1 <= p2 <= self.num_channels: raise ValueError(f"channel_2 must be between 1 and {self.num_channels}") + # This appears to be deck.get_size_x() - 562.5, but let's keep an explicit check so that we + # can catch unknown deck sizes. Can the grippers exist at another location? If so, define it as + # a resource on the robot deck and use deck.get_resource().get_absolute_location(). + deck_size = self.deck.get_absolute_size_x() + if deck_size == STARLET_SIZE_X: + xs = 7975 # 1360-797.5 = 562.5 + elif deck_size == STAR_SIZE_X: + xs = 13385 # 1900-1337.5 = 562.5, plus a manual adjustment of + 10 + else: + raise ValueError(f"Deck size {deck_size} not supported") + command_output = await self.send_command( module="C0", command="ZT", - xs="07975", + xs=f"{xs + self.core_adjustment.x:05}", xd="0", - ya="1250", - yb="1070", + ya=f"{1240 + self.core_adjustment.y:04}", + yb=f"{1065 + self.core_adjustment.y:04}", pa=f"{p1:02}", pb=f"{p2:02}", - tp="2350", - tz="2250", - th=int(self._traversal_height * 10), + tp=f"{2350 + self.core_adjustment.z:04}", + tz=f"{2250 + self.core_adjustment.z:04}", + th=round(self._traversal_height * 10), tt="14" ) self._core_parked = False @@ -4066,162 +4367,32 @@ async def get_core(self, p1: int, p2: int): @need_iswap_parked async def put_core(self): """ Put CoRe gripper tool at wasteblock mount. """ + assert self.deck is not None, "must have deck defined to access CoRe grippers" + deck_size = self.deck.get_absolute_size_x() + if deck_size == STARLET_SIZE_X: + xs = 7975 + elif deck_size == STAR_SIZE_X: + xs = 13385 + else: + raise ValueError(f"Deck size {deck_size} not supported") command_output = await self.send_command( module="C0", command="ZS", - xs="07975", + xs=f"{xs + self.core_adjustment.x:05}", xd="0", - ya="1250", - yb="1070", - tp="2150", - tz="2050", - th=int(self._traversal_height * 10), - te=int(self._traversal_height * 10), + ya=f"{1240 + self.core_adjustment.y:04}", + yb=f"{1065 + self.core_adjustment.y:04}", + tp=f"{2150 + self.core_adjustment.z:04}", + tz=f"{2050 + self.core_adjustment.z:04}", + th=round(self._traversal_height * 10), + te=round(self._traversal_height * 10), ) self._core_parked = True return command_output - async def core_pick_up_resource( - self, - resource: Resource, - pickup_distance_from_top: float, - offset: Coordinate = Coordinate.zero(), - minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, - minimum_z_position_at_the_command_end: Optional[int] = None, - grip_strength: int = 15, - z_speed: int = 500, - y_gripping_speed: int = 50, - channel_1: int = 7, - channel_2: int = 8, - ): - """ Pick up resource with CoRe gripper tool - Low level component of :meth:`move_resource` - - Args: - resource: Resource to pick up. - offset: Offset from resource position in mm. - pickup_distance_from_top: Distance from top of resource to pick up. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command [0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be - between 0 and 3600. Default 3600. - grip_strength: Grip strength (0 = weak, 99 = strong). Must be between 0 and 99. Default 15. - z_speed: Z speed [0.1mm/s]. Must be between 4 and 1287. Default 500. - y_gripping_speed: Y gripping speed [0.1mm/s]. Must be between 0 and 3700. Default 50. - channel_1: Channel 1. Must be between 0 and self._num_channels - 1. Default 7. - channel_2: Channel 2. Must be between 1 and self._num_channels. Default 8. - """ - - # Get center of source plate. Also gripping height and plate width. - center = resource.get_absolute_location() + resource.center() + offset - grip_height = center.z + resource.get_size_z() - pickup_distance_from_top - grip_width = resource.get_size_y() #grip width is y size of resource - - if self.core_parked: - await self.get_core(p1=channel_1, p2=channel_2) - - await self.core_get_plate( - x_position=int(center.x * 10), - x_direction=0, - y_position=int(center.y * 10), - y_gripping_speed=y_gripping_speed, - z_position=int(grip_height * 10), - z_speed=z_speed, - open_gripper_position=int(grip_width*10) + 30, - plate_width = int(grip_width*10) - 30, - grip_strength=grip_strength, - minimum_traverse_height_at_beginning_of_a_command=\ - minimum_traverse_height_at_beginning_of_a_command or int(self._traversal_height * 10), - minimum_z_position_at_the_command_end=minimum_z_position_at_the_command_end or - int(self._traversal_height * 10), - ) - - async def core_move_picked_up_resource( - self, - location: Coordinate, - resource: Resource, - minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, - acceleration_index: int = 4, - z_speed: int = 500, - ): - """ After a ressource is picked up, move it to a new location but don't release it yet. - Low level component of :meth:`move_resource` - - Args: - location: Location to move to. - resource: Resource to move. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command [0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be - between 0 and 3600. Default 3600. - acceleration_index: Acceleration index (0 = 0.1 mm/s2, 1 = 0.2 mm/s2, 2 = 0.5 mm/s2, - 3 = 1.0 mm/s2, 4 = 2.0 mm/s2, 5 = 5.0 mm/s2, 6 = 10.0 mm/s2, 7 = 20.0 mm/s2). Must be - between 0 and 7. Default 4. - z_speed: Z speed [0.1mm/s]. Must be between 3 and 1600. Default 500. - """ - - center = location + resource.center() - - await self.core_move_plate_to_position( - x_position=int(center.x * 10), - x_direction=0, - x_acceleration_index=acceleration_index, - y_position=int(center.y * 10), - z_position=int(center.z * 10), - z_speed=z_speed, - minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command or int(self._traversal_height * 10), - ) - - async def core_release_picked_up_resource( - self, - location: Coordinate, - resource: Resource, - pickup_distance_from_top: float, - offset: Coordinate = Coordinate.zero(), - minimum_traverse_height_at_beginning_of_a_command: Optional[int] = None, - z_position_at_the_command_end: Optional[int] = None, - return_tool: bool = True - ): - """ Place resource with CoRe gripper tool - Low level component of :meth:`move_resource` - - Args: - resource: Location to place. - pickup_distance_from_top: Distance from top of resource to place. - offset: Offset from resource position in mm. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command [0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be - between 0 and 3600. Default 3600. - z_position_at_the_command_end: Minimum z-Position at end of a command [0.1 mm] (refers to all - channels independent of tip pattern parameter 'tm'). Must be between 0 and 3600. Default - 3600. - return_tool: Return tool to wasteblock mount after placing. Default True. - """ - - # Get center of destination location. Also gripping height and plate width. - center = location + resource.center() + offset - grip_height = center.z + resource.get_size_z() - pickup_distance_from_top - grip_width = resource.get_size_y() - - await self.core_put_plate( - x_position=int(center.x * 10), - x_direction=0, - y_position=int(center.y * 10), - z_position=int(grip_height * 10), - z_press_on_distance=0, - z_speed=500, - open_gripper_position=int(grip_width*10) + 30, - minimum_traverse_height_at_beginning_of_a_command= - minimum_traverse_height_at_beginning_of_a_command or int(self._traversal_height * 10), - z_position_at_the_command_end=z_position_at_the_command_end or int(self._traversal_height*10), - return_tool=return_tool - ) - async def core_open_gripper(self): """ Open CoRe gripper tool. """ - command_output = await self.send_command( - module="C0", - command="ZO") - return command_output + return await self.send_command(module="C0", command="ZO") @need_iswap_parked async def core_get_plate( @@ -4238,7 +4409,7 @@ async def core_get_plate( minimum_traverse_height_at_beginning_of_a_command: int = 2750, minimum_z_position_at_the_command_end: int = 2750, ): - """ Get plate with CoRe gripper tool from wasteblock mount. """\ + """ Get plate with CoRe gripper tool from wasteblock mount. """ assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" @@ -4346,106 +4517,6 @@ async def core_move_plate_to_position( return command_output - async def core_check_resource_exists_at_location_center( - self, - location: Coordinate, - resource: Resource, - gripper_y_margin: float = 5, - offset: Coordinate = Coordinate.zero(), - minimum_traverse_height_at_beginning_of_a_command: int = 2750, - z_position_at_the_command_end: int = 2750, - enable_recovery: bool = True, - audio_feedback: bool = True - ) -> bool: - """ Check existence of resource with CoRe gripper tool - a "Get plate using CO-RE gripper" + error handling - Which channels are used for resource check is dependent on which channels have been used for - `STAR.get_core(p1: int, p2: int)` which is a prerequisite for this check function. - - Args: - location: Location to check for resource - resource: Resource to check for. - gripper_y_margin = Distance between the front / back wall of the resource - and the grippers during "bumping" / checking - offset: Offset from resource position in mm. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of - a command [0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). - Must be between 0 and 3600. Default 3600. - z_position_at_the_command_end: Minimum z-Position at end of a command [0.1 mm] (refers to - all channels independent of tip pattern parameter 'tm'). Must be between 0 and 3600. - Default 3600. - enable_recovery: if True will ask for user input if resource was not found - audio_feedback: enable controlling computer to emit different sounds when - finding/not finding the resource - - Returns: - bool: True if resource was found, False if resource was not found - """ - - center = location + resource.centers()[0] + offset - y_width_to_gripper_bump = resource.get_size_y() - gripper_y_margin*2 - assert 9 <= y_width_to_gripper_bump <= int(resource.get_size_y()), \ - f"width between channels must be between 9 and {resource.get_size_y()} mm" \ - " (i.e. the minimal distance between channels and the max y size of the resource" - - # Check if CoRe gripper currently in use - cores_used = not self._core_parked - if not cores_used: - raise ValueError("CoRe grippers not yet picked up.") - - # Enable recovery of failed checks - resource_found = False - try_counter = 0 - while not resource_found: - try: - await self.core_get_plate( - x_position=int(center.x * 10), - y_position=int(center.y * 10), - z_position=int(center.z * 10), - open_gripper_position=int(y_width_to_gripper_bump * 10), - plate_width=int(y_width_to_gripper_bump * 10), - # Set default values based on VENUS check_plate commands - y_gripping_speed=50, - x_direction=0, - z_speed=600, - grip_strength = 20, - # Enable mods of channel z position for check acceleration - minimum_traverse_height_at_beginning_of_a_command = \ - minimum_traverse_height_at_beginning_of_a_command, - minimum_z_position_at_the_command_end = z_position_at_the_command_end, - ) - except STARFirmwareError as exc: - for module_error in exc.errors.values(): - if module_error.trace_information == 62: - resource_found = True - else: - raise ValueError(f"Unexpected error encountered: {exc}") from exc - else: - if audio_feedback: - audio.play_not_found() - if enable_recovery: - print(f"\nWARNING: Resource '{resource.name}' not found at center" \ - f" location {(center.x, center.y, center.z)} during check no {try_counter}.") - user_prompt = input("Have you checked resource is present?" \ - "\n [ yes ] -> machine will check location again" \ - "\n [ abort ] -> machine will abort run\n Answer:" - ) - if user_prompt == "yes": - try_counter += 1 - elif user_prompt == "abort": - raise ValueError(f"Resource '{resource.name}' not found at center" \ - f" location {(center.x,center.y,center.z)}" \ - " & error not resolved -> aborted resource movement.") - else: - # Resource was not found - return False - - # Resource was found - if audio_feedback: - audio.play_got_item() - return True - - # TODO:(command:ZB) # -------------- 3.5.6 Adjustment & movement commands -------------- @@ -4615,7 +4686,7 @@ async def request_y_pos_channel_n( module="C0", command="RB", fmt="rb####", - pn=pipetting_channel_index, + pn=f"{pipetting_channel_index:02}", ) # TODO:(command:RZ): Request Z-Positions of all pipetting channels @@ -4639,7 +4710,7 @@ async def request_z_pos_channel_n( module="C0", command="RD", fmt="rd####", - pn=pipetting_channel_index, + pn=f"{pipetting_channel_index:02}", ) async def request_tip_presence(self) -> List[int]: @@ -4978,11 +5049,11 @@ async def aspirate_core_96( gamma_lld_sensitivity: int = 1, swap_speed: int = 100, settling_time: int = 5, - homogenization_volume: int = 0, - homogenization_cycles: int = 0, - homogenization_position_from_liquid_surface: int = 250, - surface_following_distance_during_homogenization: int = 0, - speed_of_homogenization: int = 1000, + mix_volume: int = 0, + mix_cycles: int = 0, + mix_position_from_liquid_surface: int = 250, + surface_following_distance_during_mix: int = 0, + speed_of_mix: int = 1000, channel_pattern: List[bool] = [True] * 96, limit_curve_index: int = 0, tadm_algorithm: bool = False, @@ -5030,13 +5101,13 @@ async def aspirate_core_96( Default 1. swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100. settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. - homogenization_volume: Homogenization volume [0.1ul]. Must be between 0 and 11500. Default 0. - homogenization_cycles: Number of homogenization cycles. Must be between 0 and 99. Default 0. - homogenization_position_from_liquid_surface: Homogenization position in Z- direction from + mix_volume: mix volume [0.1ul]. Must be between 0 and 11500. Default 0. + mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0. + mix_position_from_liquid_surface: mix position in Z- direction from liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250. - surface_following_distance_during_homogenization: surface following distance during - homogenization [0.1mm]. Must be between 0 and 990. Default 0. - speed_of_homogenization: Speed of homogenization [0.1ul/s]. Must be between 3 and 5000. + surface_following_distance_during_mix: surface following distance during + mix [0.1mm]. Must be between 0 and 990. Default 0. + speed_of_mix: Speed of mix [0.1ul/s]. Must be between 3 and 5000. Default 1000. todo: TODO: 24 hex chars. Must be between 4 and 5000. limit_curve_index: limit curve index. Must be between 0 and 999. Default 0. @@ -5076,14 +5147,14 @@ async def aspirate_core_96( assert 1 <= gamma_lld_sensitivity <= 4, "gamma_lld_sensitivity must be between 1 and 4" assert 3 <= swap_speed <= 1000, "swap_speed must be between 3 and 1000" assert 0 <= settling_time <= 99, "settling_time must be between 0 and 99" - assert 0 <= homogenization_volume <= 11500, "homogenization_volume must be between 0 and 11500" - assert 0 <= homogenization_cycles <= 99, "homogenization_cycles must be between 0 and 99" - assert 0 <= homogenization_position_from_liquid_surface <= 990, \ - "homogenization_position_from_liquid_surface must be between 0 and 990" - assert 0 <= surface_following_distance_during_homogenization <= 990, \ - "surface_following_distance_during_homogenization must be between 0 and 990" - assert 3 <= speed_of_homogenization <= 5000, \ - "speed_of_homogenization must be between 3 and 5000" + assert 0 <= mix_volume <= 11500, "mix_volume must be between 0 and 11500" + assert 0 <= mix_cycles <= 99, "mix_cycles must be between 0 and 99" + assert 0 <= mix_position_from_liquid_surface <= 990, \ + "mix_position_from_liquid_surface must be between 0 and 990" + assert 0 <= surface_following_distance_during_mix <= 990, \ + "surface_following_distance_during_mix must be between 0 and 990" + assert 3 <= speed_of_mix <= 5000, \ + "speed_of_mix must be between 3 and 5000" assert 0 <= limit_curve_index <= 999, "limit_curve_index must be between 0 and 999" assert 0 <= recording_mode <= 2, "recording_mode must be between 0 and 2" @@ -5120,11 +5191,11 @@ async def aspirate_core_96( cs=gamma_lld_sensitivity, bs=f"{swap_speed:04}", wh=f"{settling_time:02}", - hv=f"{homogenization_volume:05}", - hc=f"{homogenization_cycles:02}", - hp=f"{homogenization_position_from_liquid_surface:03}", - mj=f"{surface_following_distance_during_homogenization:03}", - hs=f"{speed_of_homogenization:04}", + hv=f"{mix_volume:05}", + hc=f"{mix_cycles:02}", + hp=f"{mix_position_from_liquid_surface:03}", + mj=f"{surface_following_distance_during_mix:03}", + hs=f"{speed_of_mix:04}", cw=channel_pattern_hex, cr=f"{limit_curve_index:03}", cj=tadm_algorithm, @@ -5215,9 +5286,9 @@ async def dispense_core_96( Must be between 0 and 45. Default 1. swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100. settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5. - mixing_volume: Homogenization volume [0.1ul]. Must be between 0 and 11500. Default 0. + mixing_volume: mix volume [0.1ul]. Must be between 0 and 11500. Default 0. mixing_cycles: Number of mixing cycles. Must be between 0 and 99. Default 0. - mixing_position_from_liquid_surface: Homogenization position in Z- direction from liquid + mixing_position_from_liquid_surface: mix position in Z- direction from liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250. surface_following_distance_during_mixing: surface following distance during mixing [0.1mm]. Must be between 0 and 990. Default 0. @@ -5534,7 +5605,7 @@ async def unload_carrier(self, carrier: Carrier): """ Use autoload to unload carrier. """ # Identify carrier end rail track_width = 22.5 - carrier_width = carrier.get_absolute_location().x - 100 + carrier.get_size_x() + carrier_width = carrier.get_absolute_location().x - 100 + carrier.get_absolute_size_x() carrier_end_rail = int(carrier_width / track_width) assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" @@ -5598,7 +5669,7 @@ async def load_carrier( } # Identify carrier end rail track_width = 22.5 - carrier_width = carrier.get_absolute_location().x - 100 + carrier.get_size_x() + carrier_width = carrier.get_absolute_location().x - 100 + carrier.get_absolute_size_x() carrier_end_rail = int(carrier_width / track_width) assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" @@ -6229,6 +6300,39 @@ async def iswap_put_plate( self._iswap_parked = False return command_output + async def iswap_rotate( + self, + position: int = 33, + gripper_velocity: int = 55_000, + gripper_acceleration: int = 170, + gripper_protection: Literal[0,1,2,3,4,5,6,7] = 5, + wrist_velocity: int = 48_000, + wrist_acceleration: int = 145, + wrist_protection: Literal[0,1,2,3,4,5,6,7] = 5, + ): + """ + Rotate the iswap to a predifined position. + Velocity units are "incr/sec" + Acceleration units are "1_000 incr/sec**2" + For a list of the possible positions see the pylabrobot documentation on the R0 module. + """ + assert 20 <= gripper_velocity <= 75_000 + assert 5 <= gripper_acceleration <= 200 + assert 20 <= wrist_velocity <= 65_000 + assert 20 <= wrist_acceleration <= 200 + + return await self.send_command( + module="R0", + command="PD", + pd=position, + wv=f"{gripper_velocity:05}", + wr=f"{gripper_acceleration:03}", + ww=gripper_protection, + tv=f"{wrist_velocity:05}", + tr=f"{wrist_acceleration:03}", + tw=wrist_protection, + ) + async def move_plate_to_position( self, x_position: int = 0, @@ -6321,8 +6425,7 @@ async def collapse_gripper_arm( # -------------- 3.17.3 Hotel handling commands -------------- - # TODO:(command:PO) Get plate from hotel - # TODO:(command:PI) Put plate to hotel + # implemented in UnSafe class # -------------- 3.17.4 Barcode commands -------------- @@ -6339,7 +6442,8 @@ async def prepare_iswap_teaching( z_position: int = 0, z_direction: int = 0, location: int = 0, - hotel_depth: int = 0, + hotel_depth: int = 1300, + grip_direction: int = 1, minimum_traverse_height_at_beginning_of_a_command: int = 3600, collision_control_level: int = 1, acceleration_index_high_acc: int = 4, @@ -6357,7 +6461,7 @@ async def prepare_iswap_teaching( z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0. - hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 13000. + hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300. minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a command 0.1mm]. Must be between 0 and 3600. Default 3600. collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. @@ -6385,15 +6489,16 @@ async def prepare_iswap_teaching( return await self.send_command( module="C0", command="PT", - xs=x_position, + xs=f"{x_position:05}", xd=x_direction, - yj=y_position, + yj=f"{y_position:04}", yd=y_direction, - zj=z_position, + zj=f"{z_position:04}", zd=z_direction, hh=location, - hd=hotel_depth, - th=minimum_traverse_height_at_beginning_of_a_command, + hd=f"{hotel_depth:04}", + gr=grip_direction, + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", ga=collision_control_level, xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}" ) @@ -6682,6 +6787,8 @@ async def start_temperature_control_at_hhs( # Ensure proper temperature input handling if isinstance(temp, (float, int)): safe_temp_str = f"{int(temp * 10):04d}" + else: + safe_temp_str = str(temp) return await self.send_command( module=f"T{device_number}", @@ -6768,7 +6875,9 @@ async def start_temperature_control_at_hhc( # Ensure proper temperature input handling if isinstance(temp, (float, int)): - safe_temp_str = f"{int(temp * 10):04d}" + safe_temp_str = f"{round(temp * 10):04d}" + else: + safe_temp_str = str(temp) return await self.send_command( module=f"T{device_number}", @@ -6892,3 +7001,159 @@ async def probe_z_height_using_channel( result_in_mm = float(get_llds["lh"][channel_idx-1] / 10) return result_in_mm + + +class UnSafe: + """ + Namespace for actions that are unsafe to perfom. + For example, actions that send the iSWAP outside of the Hamilton Deck + """ + + def __init__(self, star: "STAR"): + self.star = star + + async def put_in_hotel( + self, + hotel_center_x_coord: int = 0, + hotel_center_y_coord: int = 0, + hotel_center_z_coord: int = 0, + # for direction, 0 is positive, 1 is negative + hotel_center_x_direction: Literal[0, 1] = 0, + hotel_center_y_direction: Literal[0, 1] = 0, + hotel_center_z_direction: Literal[0, 1] = 0, + clearance_height: int = 50, + hotel_depth: int = 1_300, + grip_direction:GripDirection = GripDirection.FRONT, + traverse_height_at_beginning: int = 3_600, + z_position_at_end: int = 3_600, + grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, + open_gripper_position: int = 860, + collision_control: Literal[0, 1] = 1, + high_acceleration_index: Literal[1, 2, 3, 4] = 4, + low_acceleration_index: Literal[1, 2, 3, 4] = 1, + fold_up_at_end: bool = True, + ): + """ + A hotel is a location to store a plate. This can be a loading + dock for an external machine such as a cytomat or a centrifuge. + + Take care when using this command to interact with hotels located + outside of the hamilton deck area. Ensure that rotations of the + iSWAP arm don't collide with anything. + + tip: set the hotel depth big enough so that the boundary is inside the + hamilton deck. The iSWAP rotations will happen before it enters the hotel. + + The units of all relevant variables are in 0.1mm + """ + + assert 0 <= hotel_center_x_coord <= 99_999 + assert 0 <= hotel_center_y_coord <= 6_500 + assert 0 <= hotel_center_z_coord <= 3_500 + assert 0 <= clearance_height <= 999 + assert 0 <= hotel_depth <= 3_000 + assert 0 <= traverse_height_at_beginning <= 3_600 + assert 0 <= z_position_at_end <= 3_600 + assert 0 <= open_gripper_position <= 9_999 + + return await self.star.send_command( + module="C0", + command="PI", + xs=f"{hotel_center_x_coord:05}", + xd=hotel_center_x_direction, + yj=f"{hotel_center_y_coord:04}", + yd=hotel_center_y_direction, + zj=f"{hotel_center_z_coord:04}", + zd=hotel_center_z_direction, + zc=f"{clearance_height:03}", + hd=f"{hotel_depth:04}", + gr={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[grip_direction], + th=f"{traverse_height_at_beginning:04}", + te=f"{z_position_at_end:04}", + gw=grip_strength, + go=f"{open_gripper_position:04}", + ga=collision_control, + xe=f"{high_acceleration_index} {low_acceleration_index}", + gc=int(fold_up_at_end), + ) + + async def get_from_hotel( + self, + hotel_center_x_coord: int = 0, + hotel_center_y_coord: int = 0, + hotel_center_z_coord: int = 0, + # for direction, 0 is positive, 1 is negative + hotel_center_x_direction: Literal[0, 1] = 0, + hotel_center_y_direction: Literal[0, 1] = 0, + hotel_center_z_direction: Literal[0, 1] = 0, + clearance_height: int = 50, + hotel_depth: int = 1_300, + grip_direction:GripDirection = GripDirection.FRONT, + traverse_height_at_beginning: int = 3_600, + z_position_at_end: int = 3_600, + grip_strength: Literal[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] = 5, + open_gripper_position: int = 860, + plate_width: int = 800, + plate_width_tolerance: int = 20, + collision_control: Literal[0, 1]=1, + high_acceleration_index: Literal[1, 2, 3, 4] = 4, + low_acceleration_index: Literal[1, 2, 3, 4] = 1, + fold_up_at_end: bool = True, + ): + """ + A hotel is a location to store a plate. This can be a loading + dock for an external machine such as a cytomat or a centrifuge. + + Take care when using this command to interact with hotels located + outside of the hamilton deck area. Ensure that rotations of the + iSWAP arm don't collide with anything. + + tip: set the hotel depth big enough so that the boundary is inside the + hamilton deck. The iSWAP rotations will happen before it enters the hotel. + + The units of all relevant variables are in 0.1mm + """ + + assert 0 <= hotel_center_x_coord <= 99_999 + assert 0 <= hotel_center_y_coord <= 6_500 + assert 0 <= hotel_center_z_coord <= 3_500 + assert 0 <= clearance_height <= 999 + assert 0 <= hotel_depth <= 3_000 + assert 0 <= traverse_height_at_beginning <= 3_600 + assert 0 <= z_position_at_end <= 3_600 + assert 0 <= open_gripper_position <= 9_999 + assert 0 <= plate_width <= 9_999 + assert 0 <= plate_width_tolerance <= 99 + + return await self.star.send_command( + module="C0", + command="PO", + xs=f"{hotel_center_x_coord:05}", + xd=hotel_center_x_direction, + yj=f"{hotel_center_y_coord:04}", + yd=hotel_center_y_direction, + zj=f"{hotel_center_z_coord:04}", + zd=hotel_center_z_direction, + zc=f"{clearance_height:03}", + hd=f"{hotel_depth:04}", + gr={ + GripDirection.FRONT: 1, + GripDirection.RIGHT: 2, + GripDirection.BACK: 3, + GripDirection.LEFT: 4, + }[grip_direction], + th=f"{traverse_height_at_beginning:04}", + te=f"{z_position_at_end:04}", + gw=grip_strength, + go=f"{open_gripper_position:04}", + gb=f"{plate_width:04}", + gt=f"{plate_width_tolerance:02}", + ga=collision_control, + xe=f"{high_acceleration_index} {low_acceleration_index}", + gc=int(fold_up_at_end), + ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py index cf8b7fcffc..8d5618466c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -1,15 +1,11 @@ # pylint: disable=unused-argument -from pylabrobot.liquid_handling.backends.chatterbox_backend import ChatterBoxBackend -from pylabrobot.resources import Resource -from pylabrobot.liquid_handling.standard import ( - GripDirection, -) -from pylabrobot.resources.coordinate import Coordinate +from typing import Optional +from pylabrobot.liquid_handling.backends.hamilton.STAR import STAR -class STARChatterBoxBackend(ChatterBoxBackend): - """ Chatter box backend for 'STAR' """ +class STARChatterboxBackend(STAR): + """ Chatterbox backend for "STAR" """ def __init__(self, num_channels: int = 8): """ Initialize a chatter box backend. """ @@ -17,40 +13,38 @@ def __init__(self, num_channels: int = 8): self._num_channels = num_channels self._iswap_parked = True - @property - def iswap_parked(self) -> bool: - return self._iswap_parked is True + async def request_tip_presence(self): + return list(range(self.num_channels)) - async def get_core(self, p1: int, p2: int): - print(f"Getting core from {p1} to {p2}") + async def request_machine_configuration(self): + # configuration is directly copied from a STARlet w/ 8p, iswap, and autoload + self.conf = {"kb": 11, "kp": 8, "id": 2} + return self.conf - async def iswap_pick_up_resource( - self, - resource: Resource, - grip_direction: GripDirection, - **backend_kwargs - ): - print(f"Pick up resource {resource.name} with {grip_direction}.") + async def request_extended_configuration(self): + self._extended_conf = {"ka": 65537, "ke": 0, "xt": 30, "xa": 30, "xw": 8000, "xl": 3, "xn": 0, + "xr": 0, "xo": 0, "xm": 3500, "xx": 6000, "xu": 3700, "xv": 3700, "kc": 0, + "kr": 0, "ys": 90, "kl": 360, "km": 360, "ym": 6065, "yu": 60, "yx": 60, + "id": 3} + #extended configuration is directly copied from a STARlet w/ 8p, iswap, and autoload + return self.extended_conf - async def iswap_move_picked_up_resource( - self, - location: Coordinate, - resource: Resource, - **backend_kwargs - ): - print(f"Move picked up resource {resource.name} to {location}") + async def request_iswap_initialization_status(self) -> bool: + return True - async def iswap_release_picked_up_resource( - self, - location: Coordinate, - resource: Resource, - grip_direction: GripDirection, - **backend_kwargs - ): - print(f"Release picked up resource {resource.name} at {location} with {grip_direction}.") + @property + def iswap_parked(self) -> bool: + return self._iswap_parked is True async def send_command(self, module, command, *args, **kwargs): print(f"Sending command: {module}{command} with args {args} and kwargs {kwargs}.") - async def send_raw_command(self, command: str, **kwargs): + async def send_raw_command( + self, + command: str, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True + ) -> Optional[str]: print(f"Sending raw command: {command}") + return None diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py index 41fc697ba4..4f66539e3a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py @@ -1,30 +1,18 @@ -""" Tests for the Hamilton STAR backend. """ - from typing import cast import unittest import unittest.mock from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.liquid_handling.standard import Pickup, GripDirection from pylabrobot.plate_reading import PlateReader from pylabrobot.plate_reading.plate_reader_tests import MockPlateReaderBackend from pylabrobot.resources import ( - Container, - TIP_CAR_480_A00, - TIP_CAR_288_C00, - PLT_CAR_L5AC_A00, - Cos_96_EZWash, - HT_P, - HTF_L, - Coordinate, - ResourceStack, - Lid, + Plate, Coordinate, Container, ResourceStack, Lid, + TIP_CAR_480_A00, TIP_CAR_288_C00, PLT_CAR_L5AC_A00, HT_P, HTF_L, Cor_96_wellplate_360ul_Fb, no_volume_tracking ) from pylabrobot.resources.hamilton import STARLetDeck from pylabrobot.resources.ml_star import STF_L -from pylabrobot.liquid_handling.standard import Pickup, GripDirection -from pylabrobot.resources.plate import Plate from tests.usb import MockDev, MockEndpoint @@ -148,7 +136,7 @@ def test_parse_slave_response_errors(self): class STARUSBCommsMocker(STAR): """ Mocks PyUSB """ - async def setup(self, send_response): + async def setup(self, send_response: str): # type: ignore self.dev = MockDev(send_response) self.read_endpoint = MockEndpoint() self.write_endpoint = MockEndpoint() @@ -184,14 +172,14 @@ def __init__(self): super().__init__() self.commands = [] - async def setup(self) -> None: + async def setup(self) -> None: # type: ignore self._num_channels = 8 self.iswap_installed = True self.core96_head_installed = True self._core_parked = True - async def send_command(self, module, command, tip_pattern=None, fmt="", read_timeout=0, - write_timeout=0, **kwargs): + async def send_command(self, module, command, tip_pattern=None, fmt="", # type: ignore + read_timeout=0, write_timeout=0, **kwargs): cmd, _ = self._assemble_command(module, command, tip_pattern, **kwargs) self.commands.append(cmd) @@ -214,16 +202,23 @@ async def asyncSetUp(self): self.deck.assign_child_resource(self.tip_car, rails=1) self.plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - self.plt_car[0] = self.plate = Cos_96_EZWash(name="plate_01", with_lid=True) - self.plt_car[1] = self.other_plate = Cos_96_EZWash(name="plate_02", with_lid=True) + self.plt_car[0] = self.plate = Cor_96_wellplate_360ul_Fb(name="plate_01") + lid = Lid(name="plate_01_lid", size_x=self.plate.get_size_x(), size_y=self.plate.get_size_y(), + size_z=10, nesting_z_height=10) + self.plate.assign_child_resource(lid) + assert self.plate.lid is not None + self.plt_car[1] = self.other_plate = Cor_96_wellplate_360ul_Fb(name="plate_02") + lid = Lid(name="plate_02_lid", size_x=self.other_plate.get_size_x(), + size_y=self.other_plate.get_size_y(), size_z=10, nesting_z_height=10) + self.other_plate.assign_child_resource(lid) self.deck.assign_child_resource(self.plt_car, rails=9) class BlueBucket(Container): def __init__(self, name: str): super().__init__(name, size_x=123, size_y=82, size_z=75, category="bucket", - max_volume=123 * 82 * 75) + max_volume=123 * 82 * 75, material_z_thickness=1) self.bb = BlueBucket(name="blue bucket") - self.deck.assign_child_resource(self.bb, location=Coordinate(425, 141.5, 120)) + self.deck.assign_child_resource(self.bb, location=Coordinate(425, 141.5, 120-1)) self.maxDiff = None @@ -344,21 +339,21 @@ def test_tip_definition(self): async def test_tip_pickup_01(self): await self.lh.pick_up_tips(self.tip_rack["A1", "B1"]) self._assert_command_sent_once( - "C0TPid0000xp01179 01179 00000&yp2418 2328 0000tm1 1 0&tt01tp2243tz2163th2450td0", + "C0TPid0000xp01179 01179 00000&yp2418 2328 0000tm1 1 0&tt01tp2244tz2164th2450td0", PICKUP_TIP_FORMAT) async def test_tip_pickup_56(self): await self.lh.pick_up_tips(self.tip_rack["E1", "F1"], use_channels=[4, 5]) self._assert_command_sent_once( "C0TPid0000xp00000 00000 00000 00000 01179 01179 00000&yp0000 0000 0000 0000 2058 1968 " - "0000&tm0 0 0 0 1 1 0 &tt01tp2243tz2163th2450td0", + "0000&tm0 0 0 0 1 1 0 &tt01tp2244tz2164th2450td0", PICKUP_TIP_FORMAT) async def test_tip_pickup_15(self): await self.lh.pick_up_tips(self.tip_rack["A1", "F1"], use_channels=[0, 4]) self._assert_command_sent_once( "C0TPid0000xp01179 00000 00000 00000 01179 00000&yp2418 0000 0000 0000 1968 0000 " - "&tm1 0 0 0 1 0&tt01tp2243tz2163th2450td0", + "&tm1 0 0 0 1 0&tt01tp2244tz2164th2450td0", PICKUP_TIP_FORMAT) async def test_tip_drop_56(self): @@ -366,7 +361,7 @@ async def test_tip_drop_56(self): await self.lh.drop_tips(self.tip_rack["E1", "F1"], use_channels=[4, 5]) self._assert_command_sent_once( "C0TRid0000xp00000 00000 00000 00000 01179 01179 00000&yp0000 0000 0000 0000 2058 1968 " - "0000&tm0 0 0 0 1 1 0&tp2243tz2163th2450ti1", DROP_TIP_FORMAT) + "0000&tm0 0 0 0 1 1 0&tp2244tz2164th2450ti1", DROP_TIP_FORMAT) async def test_aspirate56(self): self.maxDiff = None @@ -377,10 +372,10 @@ async def test_aspirate56(self): well.tracker.set_liquids([(None, 100 * 1.072)]) # liquid class correction await self.lh.aspirate(self.plate["A1", "B1"], vols=[100, 100], use_channels=[4, 5]) self._assert_command_sent_once("C0ASid0004at0 0 0 0 0 0 0&tm0 0 0 0 1 1 0&xp00000 00000 00000 " - "00000 02980 02980 00000&yp0000 0000 0000 0000 1460 1370 0000&th2450te2450lp2011 2011 2011 " - "2011 2011 2011 2011&ch000 000 000 000 000 000 000&zl1881 1881 1881 1881 1881 1881 1881&" + "00000 02983 02983 00000&yp0000 0000 0000 0000 1457 1367 0000&th2450te2450lp2000 2000 2000 " + "2000 2000 2000 2000&ch000 000 000 000 000 000 000&zl1866 1866 1866 1866 1866 1866 1866&" "po0100 0100 0100 0100 0100 0100 0100&zu0032 0032 0032 0032 0032 0032 0032&zr06180 06180 " - "06180 06180 06180 06180 06180&zx1831 1831 1831 1831 1831 1831 1831&ip0000 0000 0000 0000 " + "06180 06180 06180 06180 06180&zx1866 1866 1866 1866 1866 1866 1866&ip0000 0000 0000 0000 " "0000 0000 0000&it0 0 0 0 0 0 0&fp0000 0000 0000 0000 0000 0000 0000&av01072 01072 01072 " "01072 01072 01072 01072&as1000 1000 1000 1000 1000 1000 1000&ta000 000 000 000 000 000 000&" "ba0000 0000 0000 0000 0000 0000 0000&oa000 000 000 000 000 000 000&lm0 0 0 0 0 0 0&ll1 1 1 " @@ -403,8 +398,8 @@ async def test_single_channel_aspiration(self): # This passes the test, but is not the real command. self._assert_command_sent_once( - "C0ASid0002at0 0&tm1 0&xp02980 00000&yp1460 0000&th2450te2450lp2011 2011&ch000 000&zl1881 " - "1881&po0100 0100&zu0032 0032&zr06180 06180&zx1831 1831&ip0000 0000&it0 0&fp0000 0000&" + "C0ASid0002at0 0&tm1 0&xp02983 00000&yp1457 0000&th2450te2450lp2000 2000&ch000 000&zl1866 " + "1866&po0100 0100&zu0032 0032&zr06180 06180&zx1866 1866&ip0000 0000&it0 0&fp0000 0000&" "av01072 01072&as1000 1000&ta000 000&ba0000 0000&oa000 000&lm0 0&ll1 1&lv1 1&zo000 000&" "ld00 00&de0020 0020&wt10 10&mv00000 00000&mc00 00&mp000 000&ms1000 1000&mh0000 0000&" "gi000 000&gj0gk0lk0 0&ik0000 0000&sd0500 0500&se0500 0500&sz0300 0300&io0000 0000&" @@ -418,12 +413,12 @@ async def test_single_channel_aspiration_liquid_height(self): self.plate.lid.unassign() well = self.plate.get_item("A1") well.tracker.set_liquids([(None, 100 * 1.072)]) # liquid class correction - await self.lh.aspirate([well], vols=[100], liquid_height=10) + await self.lh.aspirate([well], vols=[100], liquid_height=[10]) # This passes the test, but is not the real command. self._assert_command_sent_once( - "C0ASid0002at0 0&tm1 0&xp02980 00000&yp1460 0000&th2450te2450lp2011 2011&ch000 000&zl1971 " - "1971&po0100 0100&zu0032 0032&zr06180 06180&zx1921 1921&ip0000 0000&it0 0&fp0000 0000&" + "C0ASid0002at0 0&tm1 0&xp02983 00000&yp1457 0000&th2450te2450lp2000 2000&ch000 000&zl1966 " + "1966&po0100 0100&zu0032 0032&zr06180 06180&zx1866 1866&ip0000 0000&it0 0&fp0000 0000&" "av01072 01072&as1000 1000&ta000 000&ba0000 0000&oa000 000&lm0 0&ll1 1&lv1 1&zo000 000&" "ld00 00&de0020 0020&wt10 10&mv00000 00000&mc00 00&mp000 000&ms1000 1000&mh0000 0000&" "gi000 000&gj0gk0lk0 0&ik0000 0000&sd0500 0500&se0500 0500&sz0300 0300&io0000 0000&" @@ -438,13 +433,13 @@ async def test_multi_channel_aspiration(self): wells = self.plate.get_items("A1:B1") for well in wells: well.tracker.set_liquids([(None, 100 * 1.072)]) # liquid class correction - await self.lh.aspirate(self.plate["A1:B1"], vols=100) + await self.lh.aspirate(self.plate["A1:B1"], vols=[100]*2) # This passes the test, but is not the real command. self._assert_command_sent_once( - "C0ASid0002at0 0 0&tm1 1 0&xp02980 02980 00000&yp1460 1370 0000&th2450te2450lp2011 2011 2011&" - "ch000 000 000&zl1881 1881 1881&po0100 0100 0100&zu0032 0032 0032&zr06180 06180 06180&" - "zx1831 1831 1831&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&av01072 01072 01072&as1000 1000 " + "C0ASid0002at0 0 0&tm1 1 0&xp02983 02983 00000&yp1457 1367 0000&th2450te2450lp2000 2000 2000&" + "ch000 000 000&zl1866 1866 1866&po0100 0100 0100&zu0032 0032 0032&zr06180 06180 06180&" + "zx1866 1866 1866&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&av01072 01072 01072&as1000 1000 " "1000&ta000 000 000&ba0000 0000 0000&oa000 000 000&lm0 0 0&ll1 1 1&lv1 1 1&zo000 000 000&" "ld00 00 00&de0020 0020 0020&wt10 10 10&mv00000 00000 00000&mc00 00 00&mp000 000 000&" "ms1000 1000 1000&mh0000 0000 0000&gi000 000 000&gj0gk0lk0 0 0&ik0000 0000 0000&sd0500 0500 " @@ -455,12 +450,12 @@ async def test_multi_channel_aspiration(self): async def test_aspirate_single_resource(self): self.lh.update_head_state({i: self.tip_rack.get_tip(i) for i in range(5)}) with no_volume_tracking(): - await self.lh.aspirate(self.bb, vols=10, use_channels=[0, 1, 2, 3, 4], liquid_height=1) + await self.lh.aspirate([self.bb]*5,vols=[10]*5, use_channels=[0,1,2,3,4], liquid_height=[1]*5) self._assert_command_sent_once( - "C0ASid0002at0 0 0 0 0 0&tm1 1 1 1 1 0&xp04865 04865 04865 04865 04865 00000&yp2098 1961 " - "1825 1688 1551 0000&th2450te2450lp2000 2000 2000 2000 2000 2000&ch000 000 000 000 000 000&" + "C0ASid0002at0 0 0 0 0 0&tm1 1 1 1 1 0&xp04865 04865 04865 04865 04865 00000&yp2098 1962 " + "1825 1688 1552 0000&th2450te2450lp2000 2000 2000 2000 2000 2000&ch000 000 000 000 000 000&" "zl1210 1210 1210 1210 1210 1210&po0100 0100 0100 0100 0100 0100&zu0032 0032 0032 0032 0032 " - "0032&zr06180 06180 06180 06180 06180 06180&zx1160 1160 1160 1160 1160 1160&ip0000 0000 0000 " + "0032&zr06180 06180 06180 06180 06180 06180&zx1200 1200 1200 1200 1200 1200&ip0000 0000 0000 " "0000 0000 0000&it0 0 0 0 0 0&fp0000 0000 0000 0000 0000 0000&av00119 00119 00119 00119 " "00119 00119&as1000 1000 1000 1000 1000 1000&ta000 000 000 000 000 000&ba0000 0000 0000 0000 " "0000 0000&oa000 000 000 000 000 000&lm0 0 0 0 0 0&ll1 1 1 1 1 1&lv1 1 1 1 1 1&zo000 000 000 " @@ -475,12 +470,11 @@ async def test_aspirate_single_resource(self): async def test_dispense_single_resource(self): self.lh.update_head_state({i: self.tip_rack.get_tip(i) for i in range(5)}) with no_volume_tracking(): - await self.lh.dispense(self.bb, vols=10, use_channels=[0, 1, 2, 3, 4], liquid_height=1, - # blow_out=[True]*5, jet=[True]*5) - blow_out=[True]*5, jet=[True]*5) + await self.lh.dispense([self.bb]*5, vols=[10]*5, use_channels=[0,1,2,3,4], + liquid_height=[1]*5, blow_out=[True]*5, jet=[True]*5) self._assert_command_sent_once( - "C0DSid0002dm1 1 1 1 1 1&tm1 1 1 1 1 0&xp04865 04865 04865 04865 04865 00000&yp2098 1961 " - "1825 1688 1551 0000&zx1260 1260 1260 1260 1260 1260&lp2000 2000 2000 2000 2000 2000&zl1210 " + "C0DSid0002dm1 1 1 1 1 1&tm1 1 1 1 1 0&xp04865 04865 04865 04865 04865 00000&yp2098 1962 " + "1825 1688 1552 0000&zx1200 1200 1200 1200 1200 1200&lp2000 2000 2000 2000 2000 2000&zl1210 " "1210 1210 1210 1210 1210&po0100 0100 0100 0100 0100 0100&ip0000 0000 0000 0000 0000 0000&" "it0 0 0 0 0 0&fp0000 0000 0000 0000 0000 0000&zu0032 0032 0032 0032 0032 0032&zr06180 06180 " "06180 06180 06180 06180&th2450te2450dv00116 00116 00116 00116 00116 00116&ds1800 1800 1800 " @@ -498,7 +492,7 @@ async def test_single_channel_dispense(self): with no_volume_tracking(): await self.lh.dispense(self.plate["A1"], vols=[100], jet=[True], blow_out=[True]) self._assert_command_sent_once( - "C0DSid0002dm1 1&tm1 0&xp02980 00000&yp1460 0000&zx1931 1931&lp2011 2011&zl1881 1881&" + "C0DSid0002dm1 1&tm1 0&xp02983 00000&yp1457 0000&zx1866 1866&lp2000 2000&zl1866 1866&" "po0100 0100&ip0000 0000&it0 0&fp0000 0000&zu0032 0032&zr06180 06180&th2450te2450" "dv01072 01072&ds1800 1800&ss0050 0050&rv000 000&ta050 050&ba0300 03000&lm0 0&" "dj00zo000 000&ll1 1&lv1 1&de0010 0010&wt00 00&mv00000 00000&mc00 00&mp000 000&" @@ -511,11 +505,11 @@ async def test_multi_channel_dispense(self): assert self.plate.lid is not None self.plate.lid.unassign() with no_volume_tracking(): - await self.lh.dispense(self.plate["A1:B1"], vols=100, jet=[True]*2, blow_out=[True]*2) + await self.lh.dispense(self.plate["A1:B1"], vols=[100]*2, jet=[True]*2, blow_out=[True]*2) self._assert_command_sent_once( - "C0DSid0002dm1 1 1&tm1 1 0&xp02980 02980 00000&yp1460 1370 0000&zx1931 1931 1931&lp2011 2011 " - "2011&zl1881 1881 1881&po0100 0100 0100&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&zu0032 " + "C0DSid0002dm1 1 1&tm1 1 0&xp02983 02983 00000&yp1457 1367 0000&zx1866 1866 1866&lp2000 2000 " + "2000&zl1866 1866 1866&po0100 0100 0100&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&zu0032 " "0032 0032&zr06180 06180 06180&th2450te2450dv01072 01072 01072&ds1800 1800 1800&" "ss0050 0050 0050&rv000 000 000&ta050 050 050&ba0300 0300 0300&lm0 0 0&dj00zo000 000 000&" "ll1 1 1&lv1 1 1&de0010 0010 0010&wt00 00 00&mv00000 00000 00000&mc00 00 00&mp000 000 000&" @@ -563,7 +557,7 @@ async def test_core_96_aspirate(self): # volume used to be 01072, but that was generated using a non-core liquid class. self._assert_command_sent_once( - "C0EAid0001aa0xs02980xd0yh1460zh2450ze2450lz1999zt1881zm1871iw000ix0fh000af01083ag2500vt050" + "C0EAid0001aa0xs02983xd0yh1457zh2450ze2450lz1999zt1866zm1866iw000ix0fh000af01083ag2500vt050" "bv00000wv00050cm0cs1bs0020wh10hv00000hc00hp000hs1200zv0032zq06180mj000cj0cx0cr000" "cwFFFFFFFFFFFFFFFFFFFFFFFFpp0100", "xs#####xd#yh####zh####ze####lz####zt####zm####iw###ix#fh###af#####ag####vt###" @@ -581,7 +575,7 @@ async def test_core_96_dispense(self): # volume used to be 01072, but that was generated using a non-core liquid class. self._assert_command_sent_once( - "C0EDid0001da3xs02980xd0yh1460zh2450ze2450lz1999zt1881zm1871iw000ix0fh000df01083dg1200vt050" + "C0EDid0001da3xs02983xd0yh1457zh2450ze2450lz1999zt1866zm1866iw000ix0fh000df01083dg1200vt050" "bv00000cm0cs1bs0020wh00hv00000hc00hp000hs1200es0050ev000zv0032ej00zq06180mj000cj0cx0cr000" "cwFFFFFFFFFFFFFFFFFFFFFFFFpp0100", "da#xs#####xd#yh##6#zh####ze####lz####zt####zm##6#iw###ix#fh###df#####dg####vt###" @@ -597,37 +591,69 @@ async def test_zero_volume_liquid_handling96(self): await self.lh.dispense96(self.plate, 0) async def test_iswap(self): - await self.lh.move_plate(self.plate, self.plt_car[2]) + await self.lh.move_plate(self.plate, self.plt_car[2], pickup_distance_from_top=13.2-3.33) self._assert_command_sent_once( - "C0PPid0011xs03475xd0yj1145yd0zj1874zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + "C0PPid0011xs03479xd0yj1142yd0zj1874zd0gr1th2450te2450gw4go1308gb1245gt20ga0gc1", "xs#####xd#yj####yd#zj####zd#gr#th####te####gw#go####gb####gt##ga#gc#") self._assert_command_sent_once( - "C0PRid0012xs03475xd0yj3065yd0zj1874zd0th2450te2450gr1go1300ga0", + "C0PRid0012xs03479xd0yj3062yd0zj1874zd0th2450te2450gr1go1308ga0", "xs#####xd#yj####yd#zj####zd#th####te####go####ga#") async def test_iswap_plate_reader(self): plate_reader = PlateReader(name="plate_reader", backend=MockPlateReaderBackend(), size_x=0, size_y=0, size_z=0) self.lh.deck.assign_child_resource(plate_reader, - location=Coordinate(979.5, 285.2, 200)) # 666: 00002 - - await self.lh.move_plate(self.plate, plate_reader, pickup_distance_from_top=8.2, - get_direction=GripDirection.FRONT, put_direction=GripDirection.FRONT) - self._assert_command_sent_once( - "C0PPid0003xs03475xd0yj1145yd0zj1924zd0th2450te2450gw4gb1237go1300gt20gr1ga0gc1", + location=Coordinate(1000, 264.7, 200-3.03)) # 666: 00002 + + await self.lh.move_plate(self.plate, plate_reader, pickup_distance_from_top=8.2-3.33, + get_direction=GripDirection.FRONT, put_direction=GripDirection.LEFT) + plate_origin_location = { + "xs": "03479", + "xd": "0", + "yj": "1142", + "yd": "0", + "zj": "1924", + "zd": "0", + } + plate_reader_location = { + "xs": "10427", + "xd": "0", + "yj": "3286", + "yd": "0", + "zj": "2063", + "zd": "0", + } + self._assert_command_sent_once( + f"C0PPid0003xs{plate_origin_location['xs']}xd{plate_origin_location['xd']}" + f"yj{plate_origin_location['yj']}yd{plate_origin_location['yd']}" + f"zj{plate_origin_location['zj']}zd{plate_origin_location['zd']}" + f"th2450te2450gw4gb1245go1308gt20gr1ga0gc1", "xs#####xd#yj####yd#zj####zd#th####te####gw#gb####go####gt##gr#ga#gc#") self._assert_command_sent_once( - "C0PRid0004xs10430xd0yj3282yd0zj2063zd0th2450te2450go1300gr1ga0", + f"C0PRid0004xs{plate_reader_location['xs']}xd{plate_reader_location['xd']}" + f"yj{plate_reader_location['yj']}yd{plate_reader_location['yd']}" + f"zj{plate_reader_location['zj']}zd{plate_reader_location['zd']}" + f"th2450te2450go1308gr4ga0", "xs#####xd#yj####yd#zj####zd#th####te####go####gr#ga#") + assert self.plate.rotation.z == 270 + self.assertAlmostEqual(self.plate.get_absolute_size_x(), 85.48, places=2) + self.assertAlmostEqual(self.plate.get_absolute_size_y(), 127.76, places=2) + await self.lh.move_plate(plate_reader.get_plate(), self.plt_car[0], - pickup_distance_from_top=8.2, get_direction=GripDirection.FRONT, + pickup_distance_from_top=8.2-3.33, get_direction=GripDirection.LEFT, put_direction=GripDirection.FRONT) self._assert_command_sent_once( - "C0PPid0005xs10430xd0yj3282yd0zj2063zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + f"C0PPid0005xs{plate_reader_location['xs']}xd{plate_reader_location['xd']}" + f"yj{plate_reader_location['yj']}yd{plate_reader_location['yd']}" + f"zj{plate_reader_location['zj']}zd{plate_reader_location['zd']}" + f"gr4th2450te2450gw4go1308gb1245gt20ga0gc1", "xs#####xd#yj####yd#zj####zd#gr#th####te####gw#go####gb####gt##ga#gc#") self._assert_command_sent_once( - "C0PRid0006xs03475xd0yj1145yd0zj1924zd0th2450te2450gr1go1300ga0", + f"C0PRid0006xs{plate_origin_location['xs']}xd{plate_origin_location['xd']}" + f"yj{plate_origin_location['yj']}yd{plate_origin_location['yd']}" + f"zj{plate_origin_location['zj']}zd{plate_origin_location['zd']}" + f"th2450te2450gr1go1308ga0", "xs#####xd#yj####yd#zj####zd#th####te####gr#go####ga#") async def test_iswap_move_lid(self): @@ -636,74 +662,74 @@ async def test_iswap_move_lid(self): await self.lh.move_lid(self.plate.lid, self.other_plate) self._assert_command_sent_once( - "C0PPid0002xs03475xd0yj1145yd0zj1949zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + "C0PPid0002xs03479xd0yj1142yd0zj1950zd0gr1th2450te2450gw4go1308gb1245gt20ga0gc1", GET_PLATE_FMT) self._assert_command_sent_once( # zj sent = 1849 - "C0PRid0003xs03475xd0yj2105yd0zj1949zd0th2450te2450gr1go1300ga0", PUT_PLATE_FMT) + "C0PRid0003xs03479xd0yj2102yd0zj1950zd0th2450te2450gr1go1308ga0", PUT_PLATE_FMT) async def test_iswap_stacking_area(self): stacking_area = ResourceStack("stacking_area", direction="z") # for some reason it was like this at some point # self.lh.assign_resource(hotel, location=Coordinate(6, 414-63, 217.2 - 100)) # self.lh.deck.assign_child_resource(hotel, location=Coordinate(6, 414-63, 231.7 - 100 +4.5)) - self.lh.deck.assign_child_resource(stacking_area, location=Coordinate(6, 414, 226.2)) + self.lh.deck.assign_child_resource(stacking_area, location=Coordinate(6, 414, 226.2-3.33)) assert self.plate.lid is not None await self.lh.move_lid(self.plate.lid, stacking_area) self._assert_command_sent_once( - "C0PPid0002xs03475xd0yj1145yd0zj1949zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + "C0PPid0002xs03479xd0yj1142yd0zj1950zd0gr1th2450te2450gw4go1308gb1245gt20ga0gc1", GET_PLATE_FMT) self._assert_command_sent_once( - "C0PRid0003xs00695xd0yj4570yd0zj2305zd0th2450te2450gr1go1300ga0", PUT_PLATE_FMT) + "C0PRid0003xs00699xd0yj4567yd0zj2305zd0th2450te2450gr1go1308ga0", PUT_PLATE_FMT) # Move lids back (reverse order) await self.lh.move_lid(cast(Lid, stacking_area.get_top_item()), self.plate) self._assert_command_sent_once( - "C0PPid0004xs00695xd0yj4570yd0zj2305zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + "C0PPid0004xs00699xd0yj4567yd0zj2305zd0gr1th2450te2450gw4go1308gb1245gt20ga0gc1", GET_PLATE_FMT) self._assert_command_sent_once( - "C0PRid0005xs03475xd0yj1145yd0zj1949zd0th2450te2450gr1go1300ga0", PUT_PLATE_FMT) + "C0PRid0005xs03479xd0yj1142yd0zj1950zd0th2450te2450gr1go1308ga0", PUT_PLATE_FMT) async def test_iswap_stacking_area_2lids(self): # for some reason it was like this at some point # self.lh.assign_resource(hotel, location=Coordinate(6, 414-63, 217.2 - 100)) stacking_area = ResourceStack("stacking_area", direction="z") - self.lh.deck.assign_child_resource(stacking_area, location=Coordinate(6, 414, 226.2)) + self.lh.deck.assign_child_resource(stacking_area, location=Coordinate(6, 414, 226.2-3.33)) assert self.plate.lid is not None and self.other_plate.lid is not None await self.lh.move_lid(self.plate.lid, stacking_area) self._assert_command_sent_once( - "C0PPid0002xs03475xd0yj1145yd0zj1949zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + "C0PPid0002xs03479xd0yj1142yd0zj1950zd0gr1th2450te2450gw4go1308gb1245gt20ga0gc1", GET_PLATE_FMT) self._assert_command_sent_once( - "C0PRid0003xs00695xd0yj4570yd0zj2305zd0th2450te2450gr1go1300ga0", PUT_PLATE_FMT) + "C0PRid0003xs00699xd0yj4567yd0zj2305zd0th2450te2450gr1go1308ga0", PUT_PLATE_FMT) await self.lh.move_lid(self.other_plate.lid, stacking_area) self._assert_command_sent_once( - "C0PPid0004xs03475xd0yj2105yd0zj1949zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + "C0PPid0004xs03479xd0yj2102yd0zj1950zd0gr1th2450te2450gw4go1308gb1245gt20ga0gc1", GET_PLATE_FMT) self._assert_command_sent_once( - "C0PRid0005xs00695xd0yj4570yd0zj2405zd0th2450te2450gr1go1300ga0", PUT_PLATE_FMT) + "C0PRid0005xs00699xd0yj4567yd0zj2405zd0th2450te2450gr1go1308ga0", PUT_PLATE_FMT) # Move lids back (reverse order) top_item = stacking_area.get_top_item() assert isinstance(top_item, Lid) await self.lh.move_lid(top_item, self.plate) self._assert_command_sent_once( - "C0PPid0004xs00695xd0yj4570yd0zj2405zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + "C0PPid0004xs00699xd0yj4567yd0zj2405zd0gr1th2450te2450gw4go1308gb1245gt20ga0gc1", GET_PLATE_FMT) self._assert_command_sent_once( - "C0PRid0005xs03475xd0yj1145yd0zj1949zd0th2450te2450gr1go1300ga0", PUT_PLATE_FMT) + "C0PRid0005xs03479xd0yj1142yd0zj1950zd0th2450te2450gr1go1308ga0", PUT_PLATE_FMT) top_item = stacking_area.get_top_item() assert isinstance(top_item, Lid) await self.lh.move_lid(top_item, self.other_plate) self._assert_command_sent_once( - "C0PPid0004xs00695xd0yj4570yd0zj2305zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + "C0PPid0004xs00699xd0yj4567yd0zj2305zd0gr1th2450te2450gw4go1308gb1245gt20ga0gc1", GET_PLATE_FMT) self._assert_command_sent_once( - "C0PRid0005xs03475xd0yj2105yd0zj1949zd0th2450te2450gr1go1300ga0", PUT_PLATE_FMT) + "C0PRid0005xs03479xd0yj2102yd0zj1950zd0th2450te2450gr1go1308ga0", PUT_PLATE_FMT) async def test_iswap_move_with_intermediate_locations(self): await self.lh.move_plate(self.plate, self.plt_car[1], intermediate_locations=[ @@ -712,14 +738,14 @@ async def test_iswap_move_with_intermediate_locations(self): ]) self._assert_command_sent_once( - "C0PPid0023xs03475xd0yj1145yd0zj1874zd0gr1th2450te2450gw4go1300gb1237gt20ga0gc1", + "C0PPid0023xs03479xd0yj1142yd0zj1874zd0gr1th2450te2450gw4go1308gb1245gt20ga0gc1", GET_PLATE_FMT) self._assert_command_sent_once( - "C0PMid0025xs03975xd0yj3065yd0zj2434zd0gr1th2450ga1xe4 1", INTERMEDIATE_FMT) + "C0PMid0024xs02979xd0yj4022yd0zj2432zd0gr1th2450ga1xe4 1", INTERMEDIATE_FMT) self._assert_command_sent_once( - "C0PMid0024xs02975xd0yj4025yd0zj2434zd0gr1th2450ga1xe4 1", INTERMEDIATE_FMT) + "C0PMid0025xs03979xd0yj3062yd0zj2432zd0gr1th2450ga1xe4 1", INTERMEDIATE_FMT) self._assert_command_sent_once( - "C0PRid0026xs03475xd0yj2105yd0zj1874zd0th2450te2450gr1go1300ga0", + "C0PRid0026xs03479xd0yj2102yd0zj1874zd0th2450te2450gr1go1308ga0", PUT_PLATE_FMT) async def test_discard_tips(self): @@ -737,6 +763,8 @@ async def test_portrait_tip_rack_handling(self): lh = LiquidHandler(self.mockSTAR, deck=deck) tip_car = TIP_CAR_288_C00(name="tip carrier") tip_car[0] = tr = HT_P(name="tips_01") + assert tr.rotation.z == 90 + assert tr.location == Coordinate(82.6, 0, 0) deck.assign_child_resource(tip_car, rails=2) await lh.setup() @@ -755,27 +783,28 @@ async def test_portrait_tip_rack_handling(self): DROP_TIP_FORMAT) def test_serialize(self): - serialized = STAR().serialize() - deserialized = LiquidHandlerBackend.deserialize(serialized) - self.assertEqual(deserialized.__class__.__name__, "STAR") + serialized = LiquidHandler(backend=STAR(), deck=STARLetDeck()).serialize() + deserialized = LiquidHandler.deserialize(serialized) + self.assertEqual(deserialized.__class__.__name__, "LiquidHandler") + self.assertEqual(deserialized.backend.__class__.__name__, "STAR") async def test_move_core(self): - await self.lh.move_plate(self.plate, self.plt_car[1], pickup_distance_from_top=13, + await self.lh.move_plate(self.plate, self.plt_car[1], pickup_distance_from_top=13-3.33, use_arm="core") - self._assert_command_sent_once("C0ZTid0020xs07975xd0ya1250yb1070pa07pb08tp2350tz2250th2450tt14", + self._assert_command_sent_once("C0ZTid0020xs07975xd0ya1240yb1065pa07pb08tp2350tz2250th2450tt14", "xs#####xd#ya####yb####pa##pb##tp####tz####th####tt##") - self._assert_command_sent_once("C0ZPid0021xs03475xd0yj1145yv0050zj1876zy0500yo0890yg0830yw15" + self._assert_command_sent_once("C0ZPid0021xs03479xd0yj1142yv0050zj1876zy0500yo0885yg0825yw15" "th2450te2450", "xs#####xd#yj####yv####zj####zy####yo####yg####yw##th####te####") - self._assert_command_sent_once("C0ZRid0022xs03475xd0yj2105zj1876zi000zy0500yo0890th2450te2450", + self._assert_command_sent_once("C0ZRid0022xs03479xd0yj2102zj1876zi000zy0500yo0885th2450te2450", "xs#####xd#yj####zj####zi###zy####yo####th####te####") - self._assert_command_sent_once("C0ZSid0023xs07975xd0ya1250yb1070tp2150tz2050th2450te2450", + self._assert_command_sent_once("C0ZSid0023xs07975xd0ya1240yb1065tp2150tz2050th2450te2450", "xs#####xd#ya####yb####tp####tz####th####te####") async def test_iswap_pick_up_resource_grip_direction_changes_plate_width(self): size_x = 100 size_y = 200 - plate = Plate("dummy", size_x=size_x, size_y=size_y, size_z=100, items=[]) + plate = Plate("dummy", size_x=size_x, size_y=size_y, size_z=100, ordered_items={}) plate.location = Coordinate.zero() with unittest.mock.patch.object(self.lh.backend, "iswap_get_plate") as mocked_iswap_get_plate: @@ -789,25 +818,27 @@ async def test_iswap_pick_up_resource_grip_direction_changes_plate_width(self): async def test_iswap_release_picked_up_resource_grip_direction_changes_plate_width(self): size_x = 100 size_y = 200 - plate = Plate("dummy", size_x=size_x, size_y=size_y, size_z=100, items=[]) + plate = Plate("dummy", size_x=size_x, size_y=size_y, size_z=100, ordered_items={}) plate.location = Coordinate.zero() with unittest.mock.patch.object(self.lh.backend, "iswap_put_plate") as mocked_iswap_get_plate: await cast(STAR, self.lh.backend).iswap_release_picked_up_resource( - Coordinate.zero(), - plate, - Coordinate.zero(), - GripDirection.FRONT, - 1, + location=Coordinate.zero(), + resource=plate, + rotation=0, + offset=Coordinate.zero(), + grip_direction=GripDirection.FRONT, + pickup_distance_from_top=1, ) assert mocked_iswap_get_plate.call_args.kwargs["open_gripper_position"] == size_x * 10 + 30 with unittest.mock.patch.object(self.lh.backend, "iswap_put_plate") as mocked_iswap_get_plate: await cast(STAR, self.lh.backend).iswap_release_picked_up_resource( - Coordinate.zero(), - plate, - Coordinate.zero(), - GripDirection.LEFT, - 1, + location=Coordinate.zero(), + resource=plate, + rotation=0, + offset=Coordinate.zero(), + grip_direction=GripDirection.LEFT, + pickup_distance_from_top=1, ) assert mocked_iswap_get_plate.call_args.kwargs["open_gripper_position"] == size_y * 10 + 30 diff --git a/pylabrobot/liquid_handling/backends/hamilton/base.py b/pylabrobot/liquid_handling/backends/hamilton/base.py index 69f8fb35d2..1b8da40c5a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/base.py +++ b/pylabrobot/liquid_handling/backends/hamilton/base.py @@ -6,9 +6,10 @@ import time from typing import Any, Dict, List, Optional, Sequence, Tuple, TypeVar, cast -from pylabrobot.liquid_handling.backends.USBBackend import USBBackend +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.standard import PipettingOp -from pylabrobot.resources import TipSpot, Well +from pylabrobot.machines.backends import USBBackend +from pylabrobot.resources import TipSpot from pylabrobot.resources.ml_star import HamiltonTip, TipPickupMethod, TipSize T = TypeVar("T") @@ -16,7 +17,7 @@ logger = logging.getLogger("pylabrobot") -class HamiltonLiquidHandler(USBBackend, metaclass=ABCMeta): +class HamiltonLiquidHandler(LiquidHandlerBackend, USBBackend, metaclass=ABCMeta): """ Abstract base class for Hamilton liquid handling robot backends. """ @@ -26,6 +27,7 @@ def __init__( self, id_product: int, device_address: Optional[int] = None, + serial_number: Optional[str] = None, packet_read_timeout: int = 3, read_timeout: int = 30, write_timeout: int = 30, @@ -35,18 +37,23 @@ def __init__( Args: device_address: The USB address of the Hamilton device. Only useful if using more than one Hamilton device. + serial_number: The serial number of the Hamilton device. Only useful if using more than one + Hamilton device. packet_read_timeout: The timeout for reading packets from the Hamilton machine in seconds. read_timeout: The timeout for from the Hamilton machine in seconds. num_channels: the number of pipette channels present on the robot. """ - super().__init__( - address=device_address, + USBBackend.__init__( + self, + device_address=device_address, packet_read_timeout=packet_read_timeout, read_timeout=read_timeout, write_timeout=write_timeout, id_vendor=0x08af, - id_product=id_product) + id_product=id_product, + serial_number=serial_number) + LiquidHandlerBackend.__init__(self) self.id_ = 0 @@ -55,10 +62,27 @@ def __init__( Tuple[asyncio.AbstractEventLoop, asyncio.Future, str, float]] = {} self._tth2tti: dict[int, int] = {} # hash to tip type index + # Whether to allow the firmware to plan liquid handling operations when the y positions are + # equal (same container). This allows you to pass the same container to aspirate and dispense + # multiple times in a single call, and the onboard firmware will compute the optimal order of + # operations. This is useful for efficiency but may hurt protocol interoperability. + self.allow_firmware_planning = False + + async def setup(self): + await LiquidHandlerBackend.setup(self) + await USBBackend.setup(self) + async def stop(self): self._waiting_tasks.clear() await super().stop() + def serialize(self) -> dict: + usb_backend_serialized = USBBackend.serialize(self) + del usb_backend_serialized["id_vendor"] + del usb_backend_serialized["id_product"] + liquid_handler_serialized = LiquidHandlerBackend.serialize(self) + return {**usb_backend_serialized, **liquid_handler_serialized} + @property @abstractmethod def module_id_length(self) -> int: @@ -270,14 +294,16 @@ def _continuously_read(self) -> None: except TimeoutError: continue - logger.info("Received response: %s", resp) + if resp == "": + continue + + logger.debug("Received response: %s", resp) # Parse response. try: response_id = self.get_id_from_fw_response(resp) except ValueError as e: - if resp != "": - logger.warning("Could not parse response: %s (%s)", resp, e) + logger.warning("Could not parse response: %s (%s)", resp, e) continue for id_, (loop, fut, cmd, timeout_time) in self._waiting_tasks.items(): @@ -312,21 +338,12 @@ def _ops_to_fw_positions( x_positions.append(0) y_positions.append(0) channels_involved.append(True) - offset = ops[i].offset - - x_pos = ops[i].resource.get_absolute_location().x - if isinstance(ops[i].resource, (TipSpot, Well)): - x_pos += ops[i].resource.center().x - if offset is not None: - x_pos += offset.x - x_positions.append(int(x_pos*10)) - - y_pos = ops[i].resource.get_absolute_location().y - if isinstance(ops[i].resource, (TipSpot, Well)): - y_pos += ops[i].resource.center().y - if offset is not None: - y_pos += offset.y - y_positions.append(int(y_pos*10)) + + x_pos = ops[i].resource.get_absolute_location(x="c", y="c", z="b").x + ops[i].offset.x + x_positions.append(round(x_pos*10)) + + y_pos = ops[i].resource.get_absolute_location(x="c", y="c", z="b").y + ops[i].offset.y + y_positions.append(round(y_pos*10)) # check that the minimum d between any two y positions is >9mm # O(n^2) search is not great but this is most readable, and the max size is 16, so it's fine. @@ -338,7 +355,7 @@ def _ops_to_fw_positions( continue if x1 != x2: # channels not on the same column -> will be two operations on the machine continue - if abs(y1 - y2) < 90: + if not (self.allow_firmware_planning and y1 == y2) and abs(y1 - y2) < 90: raise ValueError(f"Minimum distance between two y positions is <9mm: {y1}, {y2}" f" (channel {channel_idx1} and {channel_idx2})") @@ -382,8 +399,8 @@ async def get_or_assign_tip_type_index(self, tip: HamiltonTip) -> int: await self.define_tip_needle( tip_type_table_index=ttti, has_filter=tip.has_filter, - tip_length=int((tip.total_tip_length - tip.fitting_depth) * 10), # in 0.1mm - maximum_tip_volume=int(tip.maximal_volume * 10), # in 0.1ul + tip_length=round((tip.total_tip_length - tip.fitting_depth) * 10), # in 0.1mm + maximum_tip_volume=round(tip.maximal_volume * 10), # in 0.1ul tip_size=tip.tip_size, pickup_method=tip.pickup_method ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage.py b/pylabrobot/liquid_handling/backends/hamilton/vantage.py index e57c827116..763cfb9c8f 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage.py @@ -16,8 +16,10 @@ DropTipRack, Aspiration, AspirationPlate, + AspirationContainer, Dispense, DispensePlate, + DispenseContainer, Move ) from pylabrobot.resources import Coordinate, Liquid, Resource, TipRack, Well @@ -324,15 +326,17 @@ class Vantage(HamiltonLiquidHandler): def __init__( self, device_address: Optional[int] = None, + serial_number: Optional[str] = None, packet_read_timeout: int = 3, - read_timeout: int = 30, + read_timeout: int = 60, write_timeout: int = 30, ): """ Create a new STAR interface. Args: - device_address: the USB device address of the Hamilton STAR. Only useful if using more than + device_address: the USB device address of the Hamilton Vantage. Only useful if using more than one Hamilton machine over USB. + serial_number: the serial number of the Hamilton Vantage. packet_read_timeout: timeout in seconds for reading a single packet. read_timeout: timeout in seconds for reading a full response. write_timeout: timeout in seconds for writing a command. @@ -344,7 +348,8 @@ def __init__( packet_read_timeout=packet_read_timeout, read_timeout=read_timeout, write_timeout=write_timeout, - id_product=0x8003) + id_product=0x8003, + serial_number=serial_number) self._iswap_parked: Optional[bool] = None self._num_channels: Optional[int] = None @@ -372,11 +377,9 @@ def _parse_response(self, resp: str, fmt: Dict[str, str]) -> dict: """ Parse a firmware response. """ return parse_vantage_fw_string(resp, fmt) - async def setup(self): - """ setup - - Creates a USB connection and finds read/write interfaces. - """ + async def setup( + self, skip_loading_cover: bool = False, skip_core96: bool = False, skip_ipg: bool = False): + """ Creates a USB connection and finds read/write interfaces. """ await super().setup() @@ -403,24 +406,25 @@ async def setup(self): ) loading_cover_initialized = await self.loading_cover_request_initialization_status() - if not loading_cover_initialized: + if not loading_cover_initialized and not skip_loading_cover: await self.loading_cover_initialize() core96_initialized = await self.core96_request_initialization_status() - if not core96_initialized: + if not core96_initialized and not skip_core96: await self.core96_initialize( x_position=7347, # TODO: get trash location from deck. y_position=2684, # TODO: get trash location from deck. minimal_traverse_height_at_begin_of_command=int(self._traversal_height * 10), minimal_height_at_command_end=int(self._traversal_height * 10), - end_z_deposit_position=2020, + end_z_deposit_position=2420, ) - ipg_initialized = await self.ipg_request_initialization_status() - if not ipg_initialized: - await self.ipg_initialize() - if not await self.ipg_get_parking_status(): - await self.ipg_park() + if not skip_ipg: + ipg_initialized = await self.ipg_request_initialization_status() + if not ipg_initialized: + await self.ipg_initialize() + if not await self.ipg_get_parking_status(): + await self.ipg_park() @property def num_channels(self) -> int: @@ -448,8 +452,8 @@ async def pick_up_tips( self, ops: List[Pickup], use_channels: List[int], - minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, - minimal_height_at_command_end: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, ): x_positions, y_positions, tip_pattern = \ self._ops_to_fw_positions(ops, use_channels) @@ -457,8 +461,7 @@ async def pick_up_tips( tips = [cast(HamiltonTip, op.resource.get_tip()) for op in ops] ttti = await self.get_ttti(tips) - max_z = max(op.resource.get_absolute_location().z + \ - (op.offset.z if op.offset is not None else 0) for op in ops) + max_z = max(op.resource.get_absolute_location().z + op.offset.z for op in ops) max_total_tip_length = max(op.tip.total_tip_length for op in ops) max_tip_length = max((op.tip.total_tip_length-op.tip.fitting_depth) for op in ops) @@ -474,12 +477,14 @@ async def pick_up_tips( y_position=y_positions, tip_pattern=tip_pattern, tip_type=ttti, - begin_z_deposit_position=[int((max_z + max_total_tip_length)*10)]*len(ops), - end_z_deposit_position=[int((max_z + max_tip_length)*10)]*len(ops), - minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command or \ - [int(self._traversal_height * 10)]*len(ops), - minimal_height_at_command_end=minimal_height_at_command_end or \ - [int(self._traversal_height * 10)]*len(ops), + begin_z_deposit_position=[round((max_z + max_total_tip_length)*10)]*len(ops), + end_z_deposit_position=[round((max_z + max_tip_length)*10)]*len(ops), + minimal_traverse_height_at_begin_of_command= + [round(th*10) for th in minimal_traverse_height_at_begin_of_command or + [self._traversal_height]]*len(ops), + minimal_height_at_command_end= + [round(th*10) for th in minimal_height_at_command_end or + [self._traversal_height]]*len(ops), tip_handling_method=[1 for _ in tips], # always appears to be 1 # tip.pickup_method.value blow_out_air_volume=[0]*len(ops), # Why is this here? Who knows. ) @@ -491,28 +496,29 @@ async def drop_tips( self, ops: List[Drop], use_channels: List[int], - minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, - minimal_height_at_command_end: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, ): """ Drop tips to a resource. """ x_positions, y_positions, channels_involved = \ self._ops_to_fw_positions(ops, use_channels) - max_z = max(op.resource.get_absolute_location().z + \ - (op.offset.z if op.offset is not None else 0) for op in ops) + max_z = max(op.resource.get_absolute_location().z + op.offset.z for op in ops) try: return await self.pip_tip_discard( x_position=x_positions, y_position=y_positions, tip_pattern=channels_involved, - begin_z_deposit_position=[int((max_z+10)*10)]*len(ops), # +10 - end_z_deposit_position=[int(max_z*10)]*len(ops), - minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command or \ - [int(self._traversal_height * 10)]*len(ops), - minimal_height_at_command_end=minimal_height_at_command_end or \ - [int(self._traversal_height * 10)]*len(ops), + begin_z_deposit_position=[round((max_z+10)*10)]*len(ops), # +10 + end_z_deposit_position=[round(max_z*10)]*len(ops), + minimal_traverse_height_at_begin_of_command= + [round(th*10) for th in minimal_traverse_height_at_begin_of_command or + [self._traversal_height]]*len(ops), + minimal_height_at_command_end= + [round(th*10) for th in minimal_height_at_command_end or + [self._traversal_height]]*len(ops), tip_handling_method=[0 for _ in ops], # Always appears to be 0, even in trash. # tip_handling_method=[TipDropMethod.DROP.value if isinstance(op.resource, TipSpot) \ # else TipDropMethod.PLACE_SHIFT.value for op in ops], @@ -537,30 +543,30 @@ async def aspirate( hlcs: Optional[List[Optional[HamiltonLiquidClass]]] = None, type_of_aspiration: Optional[List[int]] = None, - minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, - minimal_height_at_command_end: Optional[List[int]] = None, - lld_search_height: Optional[List[int]] = None, - clot_detection_height: Optional[List[int]] = None, - liquid_surface_at_function_without_lld: Optional[List[int]] = None, - pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[int]] = None, - tube_2nd_section_height_measured_from_zm: Optional[List[int]] = None, - tube_2nd_section_ratio: Optional[List[int]] = None, - minimum_height: Optional[List[int]] = None, - immersion_depth: Optional[List[int]] = None, - surface_following_distance: Optional[List[int]] = None, - transport_air_volume: Optional[List[int]] = None, - pre_wetting_volume: Optional[List[int]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, + lld_search_height: Optional[List[float]] = None, + clot_detection_height: Optional[List[float]] = None, + liquid_surface_at_function_without_lld: Optional[List[float]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[float]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[float]] = None, + tube_2nd_section_ratio: Optional[List[float]] = None, + minimum_height: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, + pre_wetting_volume: Optional[List[float]] = None, lld_mode: Optional[List[int]] = None, lld_sensitivity: Optional[List[int]] = None, pressure_lld_sensitivity: Optional[List[int]] = None, - aspirate_position_above_z_touch_off: Optional[List[int]] = None, - swap_speed: Optional[List[int]] = None, - settling_time: Optional[List[int]] = None, - mix_volume: Optional[List[int]] = None, + aspirate_position_above_z_touch_off: Optional[List[float]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_volume: Optional[List[float]] = None, mix_cycles: Optional[List[int]] = None, - mix_position_in_z_direction_from_liquid_surface: Optional[List[int]] = None, - mix_speed: Optional[List[int]] = None, - surface_following_distance_during_mixing: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[float]] = None, + mix_speed: Optional[List[float]] = None, + surface_following_distance_during_mixing: Optional[List[float]] = None, TODO_DA_5: Optional[List[int]] = None, capacitive_mad_supervision_on_off: Optional[List[int]] = None, pressure_mad_supervision_on_off: Optional[List[int]] = None, @@ -613,66 +619,70 @@ async def aspirate( self._assert_valid_resources([op.resource for op in ops]) # correct volumes using the liquid class - for op, hlc in zip(ops, hlcs): - op.volume = hlc.compute_corrected_volume(op.volume) if hlc is not None else op.volume + volumes = [hlc.compute_corrected_volume(op.volume) if hlc is not None else op.volume + for op, hlc in zip(ops, hlcs)] - well_bottoms = [op.resource.get_absolute_location().z + \ - (op.offset.z if op.offset is not None else 0) for op in ops] - liquid_surfaces_no_lld = [wb + (op.liquid_height or 0) + well_bottoms = [op.resource.get_absolute_location().z + op.offset.z + \ + op.resource.material_z_thickness for op in ops] + liquid_surfaces_no_lld = liquid_surface_at_function_without_lld or [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] # -1 compared to STAR? - lld_search_heights = [wb + op.resource.get_size_z() + \ + lld_search_heights = lld_search_height or [wb + op.resource.get_absolute_size_z() + \ (2.7-1 if isinstance(op.resource, Well) else 5) #? for wb, op in zip(well_bottoms, ops)] flow_rates = [ op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100) for op, hlc in zip(ops, hlcs)] - blow_out_air_volumes = [int((op.blow_out_air_volume or - (hlc.dispense_blow_out_volume if hlc is not None else 0))*100) - for op, hlc in zip(ops, hlcs)] + blow_out_air_volumes = [(op.blow_out_air_volume or + (hlc.dispense_blow_out_volume if hlc is not None else 0)) + for op, hlc in zip(ops, hlcs)] return await self.pip_aspirate( x_position=x_positions, y_position=y_positions, type_of_aspiration=type_of_aspiration or [0]*len(ops), tip_pattern=channels_involved, - minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command or - [int(self._traversal_height * 10)]*len(ops), - minimal_height_at_command_end=minimal_height_at_command_end or \ - [int(self._traversal_height * 10)]*len(ops), - lld_search_height=lld_search_height or [int(ls*10) for ls in lld_search_heights], - clot_detection_height=clot_detection_height or [0]*len(ops), - liquid_surface_at_function_without_lld=liquid_surface_at_function_without_lld or - [int(lsn * 10) for lsn in liquid_surfaces_no_lld], + minimal_traverse_height_at_begin_of_command= + [round(th*10) for th in minimal_traverse_height_at_begin_of_command or + [self._traversal_height]]*len(ops), + minimal_height_at_command_end= + [round(th*10) for th in minimal_height_at_command_end or + [self._traversal_height]]*len(ops), + lld_search_height=[round(ls*10) for ls in lld_search_heights], + clot_detection_height=[round(cdh*10) for cdh in clot_detection_height or [0]*len(ops)], + liquid_surface_at_function_without_lld=[round(lsn * 10) for lsn in liquid_surfaces_no_lld], pull_out_distance_to_take_transport_air_in_function_without_lld=\ - pull_out_distance_to_take_transport_air_in_function_without_lld or [109]*len(ops), - tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm or - [0]*len(ops), - tube_2nd_section_ratio=tube_2nd_section_ratio or [0]*len(ops), - minimum_height=minimum_height or [int(ls * 10) for ls in liquid_surfaces_no_lld], - immersion_depth=immersion_depth or [0]*len(ops), - surface_following_distance=surface_following_distance or [0]*len(ops), - aspiration_volume=[int(op.volume*100) for op in ops], - aspiration_speed=[int(fr * 10) for fr in flow_rates], - transport_air_volume=transport_air_volume or - [int(hlc.aspiration_air_transport_volume*10) if hlc is not None else 0 - for hlc in hlcs], - blow_out_air_volume=blow_out_air_volumes, - pre_wetting_volume=pre_wetting_volume or [0]*len(ops), + [round(pod*10) for pod in pull_out_distance_to_take_transport_air_in_function_without_lld or + [10.9]*len(ops)], + tube_2nd_section_height_measured_from_zm= + [round(t2sh*10) for t2sh in tube_2nd_section_height_measured_from_zm or [0]*len(ops)], + tube_2nd_section_ratio=[round(t2sr*10) for t2sr in tube_2nd_section_ratio or [0]*len(ops)], + minimum_height=[round(wb * 10) for wb in minimum_height or well_bottoms], + immersion_depth=[round(id_*10) for id_ in immersion_depth or [0]*len(ops)], + surface_following_distance=[round(sfd*10) for sfd in surface_following_distance or + [0]*len(ops)], + aspiration_volume=[round(vol*100) for vol in volumes], + aspiration_speed=[round(fr * 10) for fr in flow_rates], + transport_air_volume=[round(tav*10) for tav in + transport_air_volume or [hlc.aspiration_air_transport_volume if hlc is not None else 0 + for hlc in hlcs]], + blow_out_air_volume=[round(bav*100) for bav in blow_out_air_volumes], + pre_wetting_volume=[round(pwv*100) for pwv in pre_wetting_volume or [0]*len(ops)], lld_mode=lld_mode or [0]*len(ops), lld_sensitivity=lld_sensitivity or [4]*len(ops), pressure_lld_sensitivity=pressure_lld_sensitivity or [4]*len(ops), - aspirate_position_above_z_touch_off=aspirate_position_above_z_touch_off or [5]*len(ops), - swap_speed=swap_speed or [20]*len(ops), - settling_time=settling_time or [10]*len(ops), - mix_volume=mix_volume or [0]*len(ops), + aspirate_position_above_z_touch_off= + [round(apz*10) for apz in aspirate_position_above_z_touch_off or [0.5]*len(ops)], + swap_speed=[round(ss*10) for ss in swap_speed or [2]*len(ops)], + settling_time=[round(st*10) for st in settling_time or [1]*len(ops)], + mix_volume=[round(mv*100) for mv in mix_volume or [0]*len(ops)], mix_cycles=mix_cycles or [0]*len(ops), mix_position_in_z_direction_from_liquid_surface= - mix_position_in_z_direction_from_liquid_surface or [0]*len(ops), - mix_speed=mix_speed or [2500]*len(ops), - surface_following_distance_during_mixing=surface_following_distance_during_mixing or - [0]*len(ops), + [round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0]*len(ops)], + mix_speed=[round(ms*10) for ms in mix_speed or [250]*len(ops)], + surface_following_distance_during_mixing= + [round(sfdm*10) for sfdm in surface_following_distance_during_mixing or [0]*len(ops)], TODO_DA_5=TODO_DA_5 or [0]*len(ops), capacitive_mad_supervision_on_off=capacitive_mad_supervision_on_off or [0]*len(ops), pressure_mad_supervision_on_off=pressure_mad_supervision_on_off or [0]*len(ops), @@ -692,30 +702,30 @@ async def dispense( hlcs: Optional[List[Optional[HamiltonLiquidClass]]] = None, type_of_dispensing_mode: Optional[List[int]] = None, - minimum_height: Optional[List[int]] = None, - pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[int]] = None, - immersion_depth: Optional[List[int]] = None, - surface_following_distance: Optional[List[int]] = None, - tube_2nd_section_height_measured_from_zm: Optional[List[int]] = None, - tube_2nd_section_ratio: Optional[List[int]] = None, - minimal_traverse_height_at_begin_of_command: Optional[List[int]] = None, - minimal_height_at_command_end: Optional[List[int]] = None, - lld_search_height: Optional[List[int]] = None, - cut_off_speed: Optional[List[int]] = None, - stop_back_volume: Optional[List[int]] = None, - transport_air_volume: Optional[List[int]] = None, + minimum_height: Optional[List[float]] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: Optional[List[float]] = None, + immersion_depth: Optional[List[float]] = None, + surface_following_distance: Optional[List[float]] = None, + tube_2nd_section_height_measured_from_zm: Optional[List[float]] = None, + tube_2nd_section_ratio: Optional[List[float]] = None, + minimal_traverse_height_at_begin_of_command: Optional[List[float]] = None, + minimal_height_at_command_end: Optional[List[float]] = None, + lld_search_height: Optional[List[float]] = None, + cut_off_speed: Optional[List[float]] = None, + stop_back_volume: Optional[List[float]] = None, + transport_air_volume: Optional[List[float]] = None, lld_mode: Optional[List[int]] = None, - side_touch_off_distance: int = 0, - dispense_position_above_z_touch_off: Optional[List[int]] = None, + side_touch_off_distance: float = 0, + dispense_position_above_z_touch_off: Optional[List[float]] = None, lld_sensitivity: Optional[List[int]] = None, pressure_lld_sensitivity: Optional[List[int]] = None, - swap_speed: Optional[List[int]] = None, - settling_time: Optional[List[int]] = None, - mix_volume: Optional[List[int]] = None, + swap_speed: Optional[List[float]] = None, + settling_time: Optional[List[float]] = None, + mix_volume: Optional[List[float]] = None, mix_cycles: Optional[List[int]] = None, - mix_position_in_z_direction_from_liquid_surface: Optional[List[int]] = None, - mix_speed: Optional[List[int]] = None, - surface_following_distance_during_mixing: Optional[List[int]] = None, + mix_position_in_z_direction_from_liquid_surface: Optional[List[float]] = None, + mix_speed: Optional[List[float]] = None, + surface_following_distance_during_mixing: Optional[List[float]] = None, TODO_DD_2: Optional[List[int]] = None, tadm_algorithm_on_off: int = 0, limit_curve_index: Optional[List[int]] = None, @@ -774,15 +784,15 @@ async def dispense( self._assert_valid_resources([op.resource for op in ops]) # correct volumes using the liquid class - for op, hlc in zip(ops, hlcs): - op.volume = hlc.compute_corrected_volume(op.volume) if hlc is not None else op.volume + volumes = [hlc.compute_corrected_volume(op.volume) if hlc is not None else op.volume + for op, hlc in zip(ops, hlcs)] - well_bottoms = [op.resource.get_absolute_location().z + \ - (op.offset.z if op.offset is not None else 0) for op in ops] + well_bottoms = [op.resource.get_absolute_location().z + op.offset.z + \ + op.resource.material_z_thickness for op in ops] liquid_surfaces_no_lld = [wb + (op.liquid_height or 0) for wb, op in zip(well_bottoms, ops)] # -1 compared to STAR? - lld_search_heights = [wb + op.resource.get_size_z() + \ + lld_search_heights = lld_search_height or [wb + op.resource.get_absolute_size_z() + \ (2.7-1 if isinstance(op.resource, Well) else 5) #? for wb, op in zip(well_bottoms, ops)] @@ -790,8 +800,8 @@ async def dispense( op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 100) for op, hlc in zip(ops, hlcs)] - blow_out_air_volumes = [int((op.blow_out_air_volume or - (hlc.dispense_blow_out_volume if hlc is not None else 0))*100) + blow_out_air_volumes = [(op.blow_out_air_volume or + (hlc.dispense_blow_out_volume if hlc is not None else 0)) for op, hlc in zip(ops, hlcs)] type_of_dispensing_mode = type_of_dispensing_mode or \ @@ -803,42 +813,46 @@ async def dispense( y_position=y_positions, tip_pattern=channels_involved, type_of_dispensing_mode=type_of_dispensing_mode, - minimum_height=minimum_height or [int(wb*10) for wb in well_bottoms], - lld_search_height=lld_search_height or [int(sh*10) for sh in lld_search_heights], - liquid_surface_at_function_without_lld=[int(ls*10) for ls in liquid_surfaces_no_lld], + minimum_height=[round(wb*10) for wb in minimum_height or well_bottoms], + lld_search_height=[round(sh*10) for sh in lld_search_heights], + liquid_surface_at_function_without_lld=[round(ls*10) for ls in liquid_surfaces_no_lld], pull_out_distance_to_take_transport_air_in_function_without_lld= - pull_out_distance_to_take_transport_air_in_function_without_lld or [50]*len(ops), - immersion_depth=immersion_depth or [0]*len(ops), - surface_following_distance=surface_following_distance or [21]*len(ops), - tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm or - [0]*len(ops), - tube_2nd_section_ratio=tube_2nd_section_ratio or [0]*len(ops), - minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command or - [int(self._traversal_height * 10)]*len(ops), - minimal_height_at_command_end=minimal_height_at_command_end or - [int(self._traversal_height * 10)]*len(ops), - dispense_volume=[int(op.volume * 100) for op in ops], - dispense_speed=[int(fr*10) for fr in flow_rates], - cut_off_speed=cut_off_speed or [2500]*len(ops), - stop_back_volume=stop_back_volume or [0]*len(ops), - transport_air_volume=transport_air_volume or - [int(hlc.dispense_air_transport_volume*10) if hlc is not None else 0 - for hlc in hlcs], - blow_out_air_volume=blow_out_air_volumes, + [round(pod*10) for pod in pull_out_distance_to_take_transport_air_in_function_without_lld or + [5.0]*len(ops)], + immersion_depth=[round(id*10) for id in immersion_depth or [0]*len(ops)], + surface_following_distance= + [round(sfd*10) for sfd in surface_following_distance or [2.1]*len(ops)], + tube_2nd_section_height_measured_from_zm= + [round(t2sh*10) for t2sh in tube_2nd_section_height_measured_from_zm or [0]*len(ops)], + tube_2nd_section_ratio=[round(t2sr*10) for t2sr in tube_2nd_section_ratio or [0]*len(ops)], + minimal_traverse_height_at_begin_of_command= + [round(mth*10) for mth in + minimal_traverse_height_at_begin_of_command or [self._traversal_height]*len(ops)], + minimal_height_at_command_end= + [round(mh*10) for mh in minimal_height_at_command_end or [self._traversal_height]*len(ops)], + dispense_volume=[round(vol*100) for vol in volumes], + dispense_speed=[round(fr*10) for fr in flow_rates], + cut_off_speed=[round(cs*10) for cs in cut_off_speed or [250]*len(ops)], + stop_back_volume=[round(sbv*100) for sbv in stop_back_volume or [0]*len(ops)], + transport_air_volume=[round(tav*10) for tav in transport_air_volume or + [hlc.dispense_air_transport_volume if hlc is not None else 0 + for hlc in hlcs]], + blow_out_air_volume=[round(boav*100) for boav in blow_out_air_volumes], lld_mode=lld_mode or [0]*len(ops), - side_touch_off_distance=side_touch_off_distance or 0, - dispense_position_above_z_touch_off=dispense_position_above_z_touch_off or [5]*len(ops), + side_touch_off_distance=round(side_touch_off_distance*10), + dispense_position_above_z_touch_off= + [round(dpz*10) for dpz in dispense_position_above_z_touch_off or [0.5]*len(ops)], lld_sensitivity=lld_sensitivity or [1]*len(ops), pressure_lld_sensitivity=pressure_lld_sensitivity or [1]*len(ops), - swap_speed=swap_speed or [10]*len(ops), - settling_time=settling_time or [0]*len(ops), - mix_volume=mix_volume or [0]*len(ops), + swap_speed=[round(ss*10) for ss in swap_speed or [1]*len(ops)], + settling_time=[round(st*10) for st in settling_time or [0]*len(ops)], + mix_volume=[round(mv*100) for mv in mix_volume or [0]*len(ops)], mix_cycles=mix_cycles or [0]*len(ops), mix_position_in_z_direction_from_liquid_surface= - mix_position_in_z_direction_from_liquid_surface or [0]*len(ops), - mix_speed=mix_speed or [10]*len(ops), - surface_following_distance_during_mixing=surface_following_distance_during_mixing or - [0]*len(ops), + [round(mp) for mp in mix_position_in_z_direction_from_liquid_surface or [0]*len(ops)], + mix_speed=[round(ms*10) for ms in mix_speed or [1]*len(ops)], + surface_following_distance_during_mixing= + [round(sfdm*10) for sfdm in surface_following_distance_during_mixing or [0]*len(ops)], TODO_DD_2=TODO_DD_2 or [0]*len(ops), tadm_algorithm_on_off=tadm_algorithm_on_off or 0, limit_curve_index=limit_curve_index or [0]*len(ops), @@ -849,9 +863,9 @@ async def pick_up_tips96( self, pickup: PickupTipRack, tip_handling_method: int = 0, - z_deposit_position: int = 2164, - minimal_traverse_height_at_begin_of_command: Optional[int] = None, - minimal_height_at_command_end: Optional[int] = None + z_deposit_position: float = 216.4, + minimal_traverse_height_at_begin_of_command: Optional[float] = None, + minimal_height_at_command_end: Optional[float] = None ): # assert self.core96_head_installed, "96 head must be installed" tip_spot_a1 = pickup.resource.get_item("A1") @@ -859,25 +873,26 @@ async def pick_up_tips96( assert isinstance(tip_a1, HamiltonTip), "Tip type must be HamiltonTip." ttti = await self.get_or_assign_tip_type_index(tip_a1) position = tip_spot_a1.get_absolute_location() + tip_spot_a1.center() + pickup.offset + offset_z = pickup.offset.z return await self.core96_tip_pick_up( - x_position=int(position.x * 10), - y_position=int(position.y * 10), + x_position=round(position.x * 10), + y_position=round(position.y * 10), tip_type=ttti, tip_handling_method=tip_handling_method, - z_deposit_position=z_deposit_position, - minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command or - int(self._traversal_height*10), - minimal_height_at_command_end=minimal_height_at_command_end or - int(self._traversal_height*10), + z_deposit_position=round((z_deposit_position + offset_z) * 10), + minimal_traverse_height_at_begin_of_command= + round((minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10), + minimal_height_at_command_end= + round((minimal_height_at_command_end or self._traversal_height)*10) ) async def drop_tips96( self, drop: DropTipRack, - z_deposit_position: int = 2164, - minimal_traverse_height_at_begin_of_command: Optional[int] = None, - minimal_height_at_command_end: Optional[int] = None + z_deposit_position: float = 216.4, + minimal_traverse_height_at_begin_of_command: Optional[float] = None, + minimal_height_at_command_end: Optional[float] = None ): # assert self.core96_head_installed, "96 head must be installed" if isinstance(drop.resource, TipRack): @@ -886,43 +901,45 @@ async def drop_tips96( else: raise NotImplementedError("Only TipRacks are supported for dropping tips on Vantage", f"got {drop.resource}") + offset_z = drop.offset.z return await self.core96_tip_discard( - x_position=int(position.x * 10), - y_position=int(position.y * 10), - z_deposit_position=z_deposit_position, - minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command or - int(self._traversal_height * 10), - minimal_height_at_command_end=minimal_height_at_command_end or int(self._traversal_height*10) + x_position=round(position.x * 10), + y_position=round(position.y * 10), + z_deposit_position=round((z_deposit_position + offset_z) * 10), + minimal_traverse_height_at_begin_of_command= + round((minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10), + minimal_height_at_command_end= + round((minimal_height_at_command_end or self._traversal_height)*10) ) async def aspirate96( self, - aspiration: AspirationPlate, + aspiration: Union[AspirationPlate, AspirationContainer], jet: bool = False, blow_out: bool = False, hlc: Optional[HamiltonLiquidClass] = None, type_of_aspiration: int = 0, - minimal_traverse_height_at_begin_of_command: Optional[int] = None, - minimal_height_at_command_end: Optional[int] = None, - pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, - tube_2nd_section_height_measured_from_zm: int = 0, - tube_2nd_section_ratio: int = 0, - immersion_depth: int = 0, - surface_following_distance: int = 0, - transport_air_volume: Optional[int] = None, - blow_out_air_volume: Optional[int] = None, - pre_wetting_volume: int = 0, + minimal_traverse_height_at_begin_of_command: Optional[float] = None, + minimal_height_at_command_end: Optional[float] = None, + pull_out_distance_to_take_transport_air_in_function_without_lld: float = 5, + tube_2nd_section_height_measured_from_zm: float = 0, + tube_2nd_section_ratio: float = 0, + immersion_depth: float = 0, + surface_following_distance: float = 0, + transport_air_volume: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + pre_wetting_volume: float = 0, lld_mode: int = 0, lld_sensitivity: int = 4, - swap_speed: Optional[int] = None, - settling_time: Optional[int] = None, - mix_volume: int = 0, + swap_speed: Optional[float] = None, + settling_time: Optional[float] = None, + mix_volume: float = 0, mix_cycles: int = 0, - mix_position_in_z_direction_from_liquid_surface: int = 0, - surface_following_distance_during_mixing: int = 0, - mix_speed: int = 2000, + mix_position_in_z_direction_from_liquid_surface: float = 0, + surface_following_distance_during_mixing: float = 0, + mix_speed: float = 2, limit_curve_index: int = 0, tadm_channel_pattern: Optional[List[bool]] = None, tadm_algorithm_on_off: int = 0, @@ -940,11 +957,20 @@ async def aspirate96( """ # assert self.core96_head_installed, "96 head must be installed" - top_left_well = aspiration.wells[0] - position = top_left_well.get_absolute_location() + top_left_well.center() + aspiration.offset + if isinstance(aspiration, AspirationPlate): + top_left_well = aspiration.wells[0] + position = top_left_well.get_absolute_location() + top_left_well.center() + \ + aspiration.offset + Coordinate(z=top_left_well.material_z_thickness) + # -1 compared to STAR? + well_bottoms = position.z + lld_search_height = well_bottoms + top_left_well.get_absolute_size_z() + 2.7-1 + else: + position = aspiration.container.get_absolute_location(y="b") + aspiration.offset + \ + Coordinate(z=aspiration.container.material_z_thickness) + bottom = position.z + lld_search_height = bottom + aspiration.container.get_absolute_size_z() + 2.7-1 liquid_height = position.z + (aspiration.liquid_height or 0) - well_bottoms = position.z tip = aspiration.tips[0] liquid_to_be_aspirated = Liquid.WATER # default to water @@ -965,49 +991,47 @@ async def aspirate96( volume = hlc.compute_corrected_volume(aspiration.volume) if hlc is not None \ else aspiration.volume - # -1 compared to STAR? - lld_search_height = well_bottoms + top_left_well.get_size_z() + 2.7-1 - transport_air_volume = transport_air_volume or \ - (int(hlc.aspiration_air_transport_volume*10) if hlc is not None else 0) + (hlc.aspiration_air_transport_volume if hlc is not None else 0) blow_out_air_volume = blow_out_air_volume or \ - (int(hlc.aspiration_blow_out_volume * 100) if hlc is not None else 0) + (hlc.aspiration_blow_out_volume if hlc is not None else 0) flow_rate = aspiration.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 250) - swap_speed = swap_speed or (int(hlc.aspiration_swap_speed*10) if hlc is not None else 100) + swap_speed = swap_speed or (hlc.aspiration_swap_speed if hlc is not None else 100) settling_time = settling_time or \ - (int(hlc.aspiration_settling_time*10) if hlc is not None else 5) + (hlc.aspiration_settling_time if hlc is not None else 5) return await self.core96_aspiration_of_liquid( - x_position=int(position.x * 10), - y_position=int(position.y * 10), + x_position=round(position.x * 10), + y_position=round(position.y * 10), type_of_aspiration=type_of_aspiration, - minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command or - int(self._traversal_height * 10), - minimal_height_at_command_end=minimal_height_at_command_end or int(self._traversal_height*10), - lld_search_height=int(lld_search_height * 10), - liquid_surface_at_function_without_lld=int(liquid_height * 10), + minimal_traverse_height_at_begin_of_command= + round((minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10), + minimal_height_at_command_end= + round(minimal_height_at_command_end or self._traversal_height * 10), + lld_search_height=round(lld_search_height * 10), + liquid_surface_at_function_without_lld=round(liquid_height * 10), pull_out_distance_to_take_transport_air_in_function_without_lld=\ - pull_out_distance_to_take_transport_air_in_function_without_lld, - minimum_height=int(well_bottoms * 10), - tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm, - tube_2nd_section_ratio=tube_2nd_section_ratio, - immersion_depth=immersion_depth, - surface_following_distance=surface_following_distance, - aspiration_volume=int(volume * 100), - aspiration_speed=int(flow_rate * 10), - transport_air_volume=transport_air_volume, - blow_out_air_volume=blow_out_air_volume, - pre_wetting_volume=pre_wetting_volume, + round(pull_out_distance_to_take_transport_air_in_function_without_lld*10), + minimum_height=round(well_bottoms * 10), + tube_2nd_section_height_measured_from_zm=round(tube_2nd_section_height_measured_from_zm*10), + tube_2nd_section_ratio=round(tube_2nd_section_ratio*10), + immersion_depth=round(immersion_depth*10), + surface_following_distance=round(surface_following_distance*10), + aspiration_volume=round(volume * 100), + aspiration_speed=round(flow_rate * 10), + transport_air_volume=round(transport_air_volume*10), + blow_out_air_volume=round(blow_out_air_volume*100), + pre_wetting_volume=round(pre_wetting_volume*100), lld_mode=lld_mode, lld_sensitivity=lld_sensitivity, - swap_speed=swap_speed, - settling_time=settling_time, - mix_volume=mix_volume, + swap_speed=round(swap_speed*10), + settling_time=round(settling_time*10), + mix_volume=round(mix_volume*100), mix_cycles=mix_cycles, mix_position_in_z_direction_from_liquid_surface=\ - mix_position_in_z_direction_from_liquid_surface, - surface_following_distance_during_mixing=surface_following_distance_during_mixing, - mix_speed=mix_speed, + round(mix_position_in_z_direction_from_liquid_surface*100), + surface_following_distance_during_mixing=round(surface_following_distance_during_mixing*100), + mix_speed=round(mix_speed*10), limit_curve_index=limit_curve_index, tadm_channel_pattern=tadm_channel_pattern, tadm_algorithm_on_off=tadm_algorithm_on_off, @@ -1016,34 +1040,34 @@ async def aspirate96( async def dispense96( self, - dispense: DispensePlate, + dispense: Union[DispensePlate, DispenseContainer], jet: bool = False, blow_out: bool = False, # "empty" in the VENUS liquid editor empty: bool = False, # truly "empty", does not exist in liquid editor, dm4 hlc: Optional[HamiltonLiquidClass] = None, type_of_dispensing_mode: Optional[int] = None, - tube_2nd_section_height_measured_from_zm: int = 0, - tube_2nd_section_ratio: int = 0, - pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50, - immersion_depth: int = 0, - surface_following_distance: int = 29, - minimal_traverse_height_at_begin_of_command: Optional[int] = None, - minimal_height_at_command_end: Optional[int] = None, - cut_off_speed: int = 2500, - stop_back_volume: int = 0, - transport_air_volume: Optional[int] = None, - blow_out_air_volume: Optional[int] = None, + tube_2nd_section_height_measured_from_zm: float = 0, + tube_2nd_section_ratio: float = 0, + pull_out_distance_to_take_transport_air_in_function_without_lld: float = 5.0, + immersion_depth: float = 0, + surface_following_distance: float = 2.9, + minimal_traverse_height_at_begin_of_command: Optional[float] = None, + minimal_height_at_command_end: Optional[float] = None, + cut_off_speed: float = 250.0, + stop_back_volume: float = 0, + transport_air_volume: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, lld_mode: int = 0, lld_sensitivity: int = 4, - side_touch_off_distance: int = 0, - swap_speed: Optional[int] = None, - settling_time: Optional[int] = None, - mix_volume: int = 0, + side_touch_off_distance: float = 0, + swap_speed: Optional[float] = None, + settling_time: Optional[float] = None, + mix_volume: float = 0, mix_cycles: int = 0, - mix_position_in_z_direction_from_liquid_surface: int = 0, - surface_following_distance_during_mixing: int = 0, - mix_speed: Optional[int] = None, + mix_position_in_z_direction_from_liquid_surface: float = 0, + surface_following_distance_during_mixing: float = 0, + mix_speed: Optional[float] = None, limit_curve_index: int = 0, tadm_channel_pattern: Optional[List[bool]] = None, tadm_algorithm_on_off: int = 0, @@ -1064,11 +1088,20 @@ async def dispense96( determined based on the jet, blow_out, and empty parameters. """ - top_left_well = dispense.wells[0] - position = top_left_well.get_absolute_location() + top_left_well.center() + dispense.offset + if isinstance(dispense, DispensePlate): + top_left_well = dispense.wells[0] + position = top_left_well.get_absolute_location() + top_left_well.center() + \ + dispense.offset + Coordinate(z=top_left_well.material_z_thickness) + # -1 compared to STAR? + well_bottoms = position.z + lld_search_height = well_bottoms + top_left_well.get_absolute_size_z() + 2.7-1 + else: + position = dispense.container.get_absolute_location(y="b") + dispense.offset + \ + Coordinate(z=dispense.container.material_z_thickness) + bottom = position.z + lld_search_height = bottom + dispense.container.get_absolute_size_z() + 2.7-1 - liquid_height = position.z + (dispense.liquid_height or 0) + 10 # +10? - well_bottoms = position.z + liquid_height = position.z + (dispense.liquid_height or 0) + 10 tip = dispense.tips[0] liquid_to_be_dispensed = Liquid.WATER # default to WATER @@ -1088,54 +1121,52 @@ async def dispense96( volume = hlc.compute_corrected_volume(dispense.volume) if hlc is not None \ else dispense.volume - # -1 compared to STAR? - lld_search_height = well_bottoms + top_left_well.get_size_z() + 2.7-1 - transport_air_volume = transport_air_volume or \ - (int(hlc.dispense_air_transport_volume*10) if hlc is not None else 0) + (hlc.dispense_air_transport_volume if hlc is not None else 0) blow_out_air_volume = blow_out_air_volume or \ - (int(hlc.dispense_blow_out_volume * 100) if hlc is not None else 0) + (hlc.dispense_blow_out_volume if hlc is not None else 0) flow_rate = dispense.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 250) - swap_speed = swap_speed or (int(hlc.dispense_swap_speed*10) if hlc is not None else 100) + swap_speed = swap_speed or (hlc.dispense_swap_speed if hlc is not None else 100) settling_time = settling_time or \ - (int(hlc.dispense_settling_time*10) if hlc is not None else 5) - mix_speed = mix_speed or (int(hlc.dispense_mix_flow_rate*10) if hlc is not None else 100) + (hlc.dispense_settling_time if hlc is not None else 5) + mix_speed = mix_speed or (hlc.dispense_mix_flow_rate if hlc is not None else 100) type_of_dispensing_mode = type_of_dispensing_mode or \ _get_dispense_mode(jet=jet, empty=empty, blow_out=blow_out) return await self.core96_dispensing_of_liquid( - x_position=int(position.x * 10), - y_position=int(position.y * 10), + x_position=round(position.x * 10), + y_position=round(position.y * 10), type_of_dispensing_mode=type_of_dispensing_mode, - minimum_height=int(well_bottoms * 10), - tube_2nd_section_height_measured_from_zm=tube_2nd_section_height_measured_from_zm, - tube_2nd_section_ratio=tube_2nd_section_ratio, - lld_search_height=int(lld_search_height * 10), - liquid_surface_at_function_without_lld=int(liquid_height * 10), + minimum_height=round(well_bottoms * 10), + tube_2nd_section_height_measured_from_zm=round(tube_2nd_section_height_measured_from_zm*10), + tube_2nd_section_ratio=round(tube_2nd_section_ratio*10), + lld_search_height=round(lld_search_height * 10), + liquid_surface_at_function_without_lld=round(liquid_height * 10), pull_out_distance_to_take_transport_air_in_function_without_lld=\ - pull_out_distance_to_take_transport_air_in_function_without_lld, - immersion_depth=immersion_depth, - surface_following_distance=surface_following_distance, - minimal_traverse_height_at_begin_of_command=minimal_traverse_height_at_begin_of_command or - int(self._traversal_height * 10), - minimal_height_at_command_end=minimal_height_at_command_end or int(self._traversal_height*10), - dispense_volume=int(volume * 100), - dispense_speed=int(flow_rate * 10), - cut_off_speed=cut_off_speed, - stop_back_volume=stop_back_volume, - transport_air_volume=transport_air_volume, - blow_out_air_volume=blow_out_air_volume, + round(pull_out_distance_to_take_transport_air_in_function_without_lld*10), + immersion_depth=round(immersion_depth*10), + surface_following_distance=round(surface_following_distance*10), + minimal_traverse_height_at_begin_of_command= + round((minimal_traverse_height_at_begin_of_command or self._traversal_height) * 10), + minimal_height_at_command_end= + round((minimal_height_at_command_end or self._traversal_height)*10), + dispense_volume=round(volume * 100), + dispense_speed=round(flow_rate * 10), + cut_off_speed=round(cut_off_speed * 10), + stop_back_volume=round(stop_back_volume * 100), + transport_air_volume=round(transport_air_volume*10), + blow_out_air_volume=round(blow_out_air_volume*100), lld_mode=lld_mode, lld_sensitivity=lld_sensitivity, - side_touch_off_distance=side_touch_off_distance, - swap_speed=swap_speed, - settling_time=settling_time, - mix_volume=mix_volume, + side_touch_off_distance=round(side_touch_off_distance*10), + swap_speed=round(swap_speed*10), + settling_time=round(settling_time*10), + mix_volume=round(mix_volume*10), mix_cycles=mix_cycles, mix_position_in_z_direction_from_liquid_surface=\ - mix_position_in_z_direction_from_liquid_surface, - surface_following_distance_during_mixing=surface_following_distance_during_mixing, - mix_speed=mix_speed, + round(mix_position_in_z_direction_from_liquid_surface*10), + surface_following_distance_during_mixing=round(surface_following_distance_during_mixing*10), + mix_speed=round(mix_speed*10), limit_curve_index=limit_curve_index, tadm_channel_pattern=tadm_channel_pattern, tadm_algorithm_on_off=tadm_algorithm_on_off, @@ -1160,31 +1191,32 @@ async def pick_up_resource( offset: Coordinate, pickup_distance_from_top: float, grip_strength: int = 81, - plate_width_tolerance: int = 20, + plate_width_tolerance: float = 2.0, acceleration_index: int = 4, - z_clearance_height: int = 0, - hotel_depth: int = 0, - minimal_height_at_command_end: int = 2840, + z_clearance_height: float = 0, + hotel_depth: float = 0, + minimal_height_at_command_end: float = 284.0, ): """ Pick up a resource with the IPG. You probably want to use :meth:`move_resource`, which allows you to pick up and move a resource with a single command. """ - center = resource.get_absolute_location() + resource.center() + offset - grip_height = center.z + resource.get_size_z() - pickup_distance_from_top - plate_width = resource.get_size_x() + center = resource.get_absolute_location(x="c", y="c", z="b") + offset + grip_height = center.z + resource.get_absolute_size_z() - pickup_distance_from_top + plate_width = resource.get_absolute_size_x() await self.ipg_grip_plate( - x_position=int(center.x * 10), - y_position=int(center.y * 10), - z_position=int(grip_height * 10), + x_position=round(center.x * 10), + y_position=round(center.y * 10), + z_position=round(grip_height * 10), grip_strength=grip_strength, - open_gripper_position=int(plate_width*10) + 32, - plate_width=int(plate_width * 10) - 33, - plate_width_tolerance=plate_width_tolerance, + open_gripper_position=round(plate_width*10) + 32, + plate_width=round(plate_width * 10) - 33, + plate_width_tolerance=round(plate_width_tolerance*10), acceleration_index=acceleration_index, - z_clearance_height=z_clearance_height, - hotel_depth=hotel_depth, - minimal_height_at_command_end=minimal_height_at_command_end, + z_clearance_height=round(z_clearance_height*10), + hotel_depth=round(hotel_depth*10), + minimal_height_at_command_end= + round((minimal_height_at_command_end or self._traversal_height) * 10), ) async def move_picked_up_resource(self): @@ -1202,10 +1234,10 @@ async def release_picked_up_resource( destination: Coordinate, offset: Coordinate, pickup_distance_from_top: float, - z_clearance_height: int = 0, + z_clearance_height: float = 0, press_on_distance: int = 5, - hotel_depth: int = 0, - minimal_height_at_command_end: int = 2840 + hotel_depth: float = 0, + minimal_height_at_command_end: float = 284.0 ): """ Release a resource picked up with the IPG. See :meth:`pick_up_resource`. @@ -1214,18 +1246,19 @@ async def release_picked_up_resource( """ center = destination + resource.center() + offset - grip_height = center.z + resource.get_size_z() - pickup_distance_from_top - plate_width = resource.get_size_x() + grip_height = center.z + resource.get_absolute_size_z() - pickup_distance_from_top + plate_width = resource.get_absolute_size_x() await self.ipg_put_plate( - x_position=int(center.x * 10), - y_position=int(center.y * 10), - z_position=int(grip_height * 10), - z_clearance_height=z_clearance_height, - open_gripper_position=int(plate_width*10) + 32, + x_position=round(center.x * 10), + y_position=round(center.y * 10), + z_position=round(grip_height * 10), + z_clearance_height=round(z_clearance_height*10), + open_gripper_position=round(plate_width*10) + 32, press_on_distance=press_on_distance, - hotel_depth=hotel_depth, - minimal_height_at_command_end=minimal_height_at_command_end + hotel_depth=round(hotel_depth*10), + minimal_height_at_command_end= + round((minimal_height_at_command_end or self._traversal_height) * 10), ) async def prepare_for_manual_channel_operation(self, channel: int): @@ -1236,17 +1269,17 @@ async def prepare_for_manual_channel_operation(self, channel: int): async def move_channel_x(self, channel: int, x: float): # pylint: disable=unused-argument """ Move the specified channel to the specified x coordinate. """ - return await self.x_arm_move_to_x_position(int(x * 10)) + return await self.x_arm_move_to_x_position(round(x * 10)) async def move_channel_y(self, channel: int, y: float): """ Move the specified channel to the specified y coordinate. """ - return await self.position_single_channel_in_y_direction(channel + 1, int(y * 10)) + return await self.position_single_channel_in_y_direction(channel + 1, round(y * 10)) async def move_channel_z(self, channel: int, z: float): """ Move the specified channel to the specified z coordinate. """ - return await self.position_single_channel_in_z_direction(channel + 1, int(z * 10)) + return await self.position_single_channel_in_z_direction(channel + 1, round(z * 10)) # ============== Firmware Commands ============== @@ -2080,7 +2113,7 @@ async def pip_dispense( zr=tube_2nd_section_ratio, th=minimal_traverse_height_at_begin_of_command, te=minimal_height_at_command_end, - dv=dispense_volume, + dv=[f"{vol:04}" for vol in dispense_volume], # it appears at least 4 digits are needed ds=dispense_speed, ss=cut_off_speed, rv=stop_back_volume, diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py index d96ec0799e..920262baae 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py @@ -1,13 +1,9 @@ +from typing import Any, List, Optional import unittest from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.resources import ( - TIP_CAR_480_A00, - PLT_CAR_L5AC_A00, - Cos_96_EZWash, - HT_L, - LT_L, - Coordinate, + Coordinate, TIP_CAR_480_A00, PLT_CAR_L5AC_A00, HT_L, LT_L, Cor_96_wellplate_360ul_Fb, ) from pylabrobot.resources.hamilton import VantageDeck from pylabrobot.liquid_handling.standard import Pickup @@ -119,14 +115,23 @@ def __init__(self): super().__init__() self.commands = [] - async def setup(self) -> None: + async def setup(self) -> None: # type: ignore self.setup_finished = True self._num_channels = 8 self.iswap_installed = True self.core96_head_installed = True - async def send_command(self, module, command, tip_pattern=None, read_timeout=0, - write_timeout=0, **kwargs): + async def send_command( + self, + module: str, + command: str, + tip_pattern: Optional[List[bool]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait = True, + fmt: Optional[Any] = None, + **kwargs + ): cmd, _ = self._assemble_command(module, command, tip_pattern, **kwargs) self.commands.append(cmd) @@ -149,8 +154,8 @@ async def asyncSetUp(self): self.deck.assign_child_resource(self.tip_car, rails=18) self.plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - self.plt_car[0] = self.plate = Cos_96_EZWash(name="plate_01", with_lid=False) - self.plt_car[1] = self.other_plate = Cos_96_EZWash(name="plate_02", with_lid=False) + self.plt_car[0] = self.plate = Cor_96_wellplate_360ul_Fb(name="plate_01") + self.plt_car[1] = self.other_plate = Cor_96_wellplate_360ul_Fb(name="plate_02") self.deck.assign_child_resource(self.plt_car, rails=24) self.maxDiff = None @@ -244,7 +249,7 @@ def test_tip_definition(self): async def test_tip_pickup_01(self): await self.lh.pick_up_tips(self.tip_rack["A1", "B1"]) self._assert_command_sent_once( - "A1PMTPid0012xp4329 4329 0&yp1458 1368 0&tm1 1 0&tt1 1&tp2265 2265&tz2165 2165&th2450 2450&" + "A1PMTPid0012xp4329 4329 0&yp1458 1368 0&tm1 1 0&tt1 1&tp2266 2266&tz2166 2166&th2450 2450&" "te2450 2450&ba0 0&td1 1&", PICKUP_TIP_FORMAT) @@ -259,7 +264,7 @@ async def test_tip_drop_01(self): async def test_small_tip_pickup(self): await self.lh.pick_up_tips(self.small_tip_rack["A1"]) self._assert_command_sent_once( - "A1PMTPid0010xp4329 0&yp2418 0&tm1 0&tt1&tp2223&tz2163&th2450&te2450&ba0&td1&", + "A1PMTPid0010xp4329 0&yp2418 0&tm1 0&tt1&tp2224&tz2164&th2450&te2450&ba0&td1&", PICKUP_TIP_FORMAT) async def test_small_tip_drop(self): @@ -271,23 +276,23 @@ async def test_small_tip_drop(self): async def test_aspirate(self): await self.lh.pick_up_tips(self.tip_rack["A1"]) # pick up tips first - await self.lh.aspirate(self.plate["A1"], vols=100) + await self.lh.aspirate(self.plate["A1"], vols=[100]) self._assert_command_sent_once( - "A1PMDAid0248at0&tm1 0&xp05680 0&yp1460 0 &th2450&te2450&lp2001&" - "ch000&zl1871&zx1871&ip0000&fp0000&av010830&as2500&ta000&ba00000&oa000&lm0&ll4&lv4&de0020&" + "A1PMDAid0248at0&tm1 0&xp05683 0&yp1457 0 &th2450&te2450&lp1990&" + "ch000&zl1866&zx1866&ip0000&fp0000&av010830&as2500&ta000&ba00000&oa000&lm0&ll4&lv4&de0020&" "wt10&mv00000&mc00&mp000&ms2500&gi000&gj0gk0zu0000&zr00000&mh0000&zo005&po0109&dj0la0&lb0&" "lc0&", ASPIRATE_FORMAT) async def test_dispense(self): await self.lh.pick_up_tips(self.tip_rack["A1"]) # pick up tips first - await self.lh.aspirate(self.plate["A1"], vols=100) - await self.lh.dispense(self.plate["A2"], vols=100, liquid_height=[5], jet=[False], + await self.lh.aspirate(self.plate["A1"], vols=[100]) + await self.lh.dispense(self.plate["A2"], vols=[100], liquid_height=[5], jet=[False], blow_out=[True]) self._assert_command_sent_once( - "A1PMDDid0253dm3&tm1 0&xp05770 0&yp1460 0&zx1871&lp2001&zl1921&" + "A1PMDDid0253dm3&tm1 0&xp05773 0&yp1457 0&zx1866&lp1990&zl1916&" "ip0000&fp0021&th2450&te2450&dv010830&ds1200&ss2500&rv000&ta050&ba00000&lm0&zo005&ll1&lv1&" "de0010&mv00000&mc00&mp000&ms0010&wt00&gi000&gj0gk0zu0000&dj00zr00000&mh0000&po0050&la0&", DISPENSE_FORMAT) @@ -295,8 +300,8 @@ async def test_dispense(self): async def test_zero_volume_liquid_handling(self): # just test that this does not throw an error await self.lh.pick_up_tips(self.tip_rack["A1"]) # pick up tips first - await self.lh.aspirate(self.plate["A1"], vols=0) - await self.lh.dispense(self.plate["A2"], vols=0) + await self.lh.aspirate(self.plate["A1"], vols=[0]) + await self.lh.dispense(self.plate["A2"], vols=[0]) async def test_tip_pickup96(self): await self.lh.pick_up_tips96(self.tip_rack) @@ -315,7 +320,7 @@ async def test_aspirate96(self): await self.lh.pick_up_tips96(self.tip_rack) await self.lh.aspirate96(self.plate, volume=100, jet=True, blow_out=True) self._assert_command_sent_once( - "A1HMDAid0236at0xp05680yp1460th2450te2450lp2001zl1871zx1871ip000fp000av010720as2500ta050" + "A1HMDAid0236at0xp05683yp1457th2450te2450lp1990zl1866zx1866ip000fp000av010720as2500ta050" "ba004000oa00000lm0ll4de0020wt10mv00000mc00mp000ms2500zu0000zr00000mh000gj0gk0gi000" "cwFFFFFFFFFFFFFFFFFFFFFFFFpo0050", {"xp": "int", "yp": "int", "th": "int", "te": "int", "lp": "int", "zl": "int", "zx": "int", @@ -329,7 +334,7 @@ async def test_dispense96(self): await self.lh.aspirate96(self.plate, volume=100, jet=True, blow_out=True) await self.lh.dispense96(self.plate, volume=100, jet=True, blow_out=True) self._assert_command_sent_once( - "A1HMDDid0238dm1xp05680yp1460th2450te2450lp2001zl1971zx1871ip000fp029dv010720ds4000ta050" + "A1HMDDid0238dm1xp05683yp1457th2450te2450lp1990zl1966zx1866ip000fp029dv010720ds4000ta050" "ba004000lm0ll4de0010wt00mv00000mc00mp000ms0010ss2500rv000zu0000dj00zr00000mh000gj0gk0gi000" "cwFFFFFFFFFFFFFFFFFFFFFFFFpo0050", {"xp": "int", "yp": "int", "th": "int", "te": "int", "lp": "int", "zl": "int", "zx": "int", @@ -345,15 +350,15 @@ async def test_zero_volume_liquid_handling96(self): await self.lh.dispense96(self.plate, volume=0) async def test_move_plate(self): - await self.lh.move_plate(self.plate, self.plt_car[1], pickup_distance_from_top=5.2) + await self.lh.move_plate(self.plate, self.plt_car[1], pickup_distance_from_top=5.2-3.33) # pickup self._assert_command_sent_once( - "A1RMDGid0240xp6175yp1145zp1954yw81yo1302yg1237pt20zc0hd0te2840", + "A1RMDGid0240xp6179yp1142zp1954yw81yo1310yg1245pt20zc0hd0te2840", {"xp": "int", "yp": "int", "zp": "int", "yw": "int", "yo": "int", "yg": "int", "pt": "int", "zc": "int", "hd": "int", "te": "int"}) # release self._assert_command_sent_once( - "A1RMDRid0242xp6175yp2105zp1954yo1302zc0hd0te2840", + "A1RMDRid0242xp6179yp2102zp1954yo1310zc0hd0te2840", {"xp": "int", "yp": "int", "zp": "int", "yo": "int", "zc": "int", "hd": "int", "te": "int"}) diff --git a/pylabrobot/liquid_handling/backends/http_tests.py b/pylabrobot/liquid_handling/backends/http_tests.py index 8ed2990f99..ef03fdcd90 100644 --- a/pylabrobot/liquid_handling/backends/http_tests.py +++ b/pylabrobot/liquid_handling/backends/http_tests.py @@ -10,7 +10,7 @@ PLT_CAR_L5AC_A00, TIP_CAR_480_A00, HTF_L, - Cos_96_EZWash, + Cor_96_wellplate_360ul_Fb, no_tip_tracking, no_volume_tracking ) @@ -75,7 +75,7 @@ async def asyncSetUp(self) -> None: # type: ignore self.tip_carrier = TIP_CAR_480_A00(name="tip_carrier") self.tip_carrier[0] = self.tip_rack = HTF_L(name="tiprack") self.plate_carrier = PLT_CAR_L5AC_A00(name="plate_carrier") - self.plate_carrier[0] = self.plate = Cos_96_EZWash(name="plate") + self.plate_carrier[0] = self.plate = Cor_96_wellplate_360ul_Fb(name="plate") self.deck.assign_child_resource(self.tip_carrier, rails=3) self.deck.assign_child_resource(self.plate_carrier, rails=15) @@ -132,7 +132,7 @@ async def test_aspirate(self): self.lh.update_head_state({0: self.tip_rack.get_tip("A1")}) well = self.plate.get_item("A1") well.tracker.set_liquids([(None, 10)]) - await self.lh.aspirate([well], 10) + await self.lh.aspirate([well], [10]) @responses.activate async def test_dispense(self): @@ -146,7 +146,7 @@ async def test_dispense(self): self.lh.update_head_state({0: self.tip_rack.get_tip("A1")}) self.lh.head[0].get_tip().tracker.add_liquid(None, 10) with no_volume_tracking(): - await self.lh.dispense(self.plate["A1"], 10) + await self.lh.dispense(self.plate["A1"], [10]) @responses.activate async def test_pick_up_tips96(self): diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend.py b/pylabrobot/liquid_handling/backends/opentrons_backend.py index 59ef03d0e3..cd7f3f5e60 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend.py +++ b/pylabrobot/liquid_handling/backends/opentrons_backend.py @@ -1,5 +1,5 @@ import sys -from typing import Dict, Optional, List, cast +from typing import Dict, Optional, List, cast, Union from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.errors import NoChannelError @@ -10,8 +10,10 @@ DropTipRack, Aspiration, AspirationPlate, + AspirationContainer, Dispense, DispensePlate, + DispenseContainer, Move ) from pylabrobot.resources import ( @@ -22,8 +24,7 @@ TipRack, TipSpot ) -from pylabrobot.resources.opentrons import OTDeck -from pylabrobot.temperature_controlling import OpentronsTemperatureModuleV2 +from pylabrobot.resources.opentrons import OTDeck, OTModule from pylabrobot import utils PYTHON_VERSION = sys.version_info[:2] @@ -73,6 +74,7 @@ def __init__(self, host: str, port: int = 31950): self.host = host self.port = port + # pylint: disable=possibly-used-before-assignment ot_api.set_host(host) ot_api.set_port(port) @@ -89,7 +91,7 @@ def serialize(self) -> dict: } async def setup(self): - await super().setup() + # pylint: disable=possibly-used-before-assignment # create run run_id = ot_api.runs.create() @@ -110,12 +112,14 @@ def num_channels(self) -> int: async def stop(self): self.defined_labware = {} - await super().stop() - def _get_resource_slot(self, resource: Resource) -> int: - """ Get the ultimate slot of a given resource. Some resources are assigned to another resource, - such as a temperature controller, and we need to find the slot of the parent resource. Nesting - may be deeper than one level, so we need to traverse the tree from the bottom up. """ + def _get_resource_ot_location(self, resource: Resource) -> Union[str, int]: + """ Get the OT location (slot or area) of a given resource. Some resources are assigned to + another resource, such as plates on a temperature controller, and we need to find the slot of + the parent resource (site). """ + + if isinstance(resource.parent, OTModule): + return self.defined_labware[resource.parent.name] slot = None while resource.parent is not None: @@ -144,17 +148,25 @@ async def assigned_resource_callback(self, resource: Resource): resource.name == "trash_container": return - slot = self._get_resource_slot(resource) + ot_location = self._get_resource_ot_location(resource) # check if resource is actually a Module - if isinstance(resource, OpentronsTemperatureModuleV2): + if isinstance(resource, OTModule): + assert isinstance(ot_location, int) + # pylint: disable=possibly-used-before-assignment ot_api.modules.load_module( - slot=slot, - model="temperatureModuleV2", - module_id=resource.backend.opentrons_id + slot=ot_location, + model=resource.model, + module_id=resource.backend.opentrons_id # type: ignore ) - # call self to assign the tube rack - await self.assigned_resource_callback(resource.tube_rack) + + self.defined_labware[resource.name] = resource.backend.opentrons_id # type: ignore + + # call self to assign the child to module + if hasattr(resource, "child") and resource.child is not None: + await self.assigned_resource_callback(resource.child) + else: + raise RuntimeError(f"Module {resource.name} must have a child when it assigned.") return well_names = [well.name for well in resource.children] @@ -179,14 +191,14 @@ def _get_volume(well: Resource) -> float: well_definitions = { child.name: { - "depth": child.get_size_z(), - "x": cast(Coordinate, child.location).x, - "y": cast(Coordinate, child.location).y, + "depth": child.get_absolute_size_z(), + "x": cast(Coordinate, child.location).x + child.get_absolute_size_x() / 2, + "y": cast(Coordinate, child.location).y + child.get_absolute_size_y() / 2, "z": cast(Coordinate, child.location).z, "shape": "circular", # inscribed circle has diameter equal to the width of the well - "diameter": child.get_size_x(), + "diameter": child.get_absolute_size_x(), # Opentrons requires `totalLiquidVolume`, even for tip racks! "totalLiquidVolume": _get_volume(child), @@ -235,9 +247,9 @@ def _get_volume(well: Resource) -> float: "z": 0 }, "dimensions":{ - "xDimension": resource.get_size_x(), - "yDimension": resource.get_size_y(), - "zDimension": resource.get_size_z(), + "xDimension": resource.get_absolute_size_x(), + "yDimension": resource.get_absolute_size_y(), + "zDimension": resource.get_absolute_size_z(), }, "wells": well_definitions, "groups": [ @@ -261,7 +273,7 @@ def _get_volume(well: Resource) -> float: ot_api.labware.add( load_name=definition, namespace=namespace, - slot=slot, + ot_location=ot_location, version=version, labware_id=labware_uuid, display_name=resource.name) @@ -319,10 +331,7 @@ async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]): if not pipette_id: raise NoChannelError("No pipette channel of right type with no tip available.") - if op.offset is not None: - offset_x, offset_y, offset_z = op.offset.x, op.offset.y, op.offset.z - else: - offset_x = offset_y = offset_z = 0 + offset_x, offset_y, offset_z = op.offset.x, op.offset.y, op.offset.z # ad-hoc offset adjustment that makes it smoother. offset_z += 50 @@ -358,10 +367,7 @@ async def drop_tips(self, ops: List[Drop], use_channels: List[int]): if not pipette_id: raise NoChannelError("No pipette channel of right type with tip available.") - if op.offset is not None: - offset_x, offset_y, offset_z = op.offset.x, op.offset.y, op.offset.z - else: - offset_x = offset_y = offset_z = 0 + offset_x, offset_y, offset_z = op.offset.x, op.offset.y, op.offset.z # ad-hoc offset adjustment that makes it smoother. offset_z += 10 @@ -460,10 +466,7 @@ async def aspirate(self, ops: List[Aspiration], use_channels: List[int]): labware_id = self.defined_labware[op.resource.parent.name] - if op.offset is not None: - offset_x, offset_y, offset_z = op.offset.x, op.offset.y, op.offset.z - else: - offset_x = offset_y = offset_z = 0 + offset_x, offset_y, offset_z = op.offset.x, op.offset.y, op.offset.z ot_api.lh.aspirate(labware_id, well_name=op.resource.name, pipette_id=pipette_id, volume=volume, flow_rate=flow_rate, offset_x=offset_x, offset_y=offset_y, offset_z=offset_z) @@ -513,10 +516,7 @@ async def dispense(self, ops: List[Dispense], use_channels: List[int]): labware_id = self.defined_labware[op.resource.parent.name] - if op.offset is not None: - offset_x, offset_y, offset_z = op.offset.x, op.offset.y, op.offset.z - else: - offset_x = offset_y = offset_z = 0 + offset_x, offset_y, offset_z = op.offset.x, op.offset.y, op.offset.z ot_api.lh.dispense(labware_id, well_name=op.resource.name, pipette_id=pipette_id, volume=volume, flow_rate=flow_rate, offset_x=offset_x, offset_y=offset_y, offset_z=offset_z) @@ -531,10 +531,10 @@ async def pick_up_tips96(self, pickup: PickupTipRack): async def drop_tips96(self, drop: DropTipRack): raise NotImplementedError("The Opentrons backend does not support the CoRe 96.") - async def aspirate96(self, aspiration: AspirationPlate): + async def aspirate96(self, aspiration: Union[AspirationPlate, AspirationContainer]): raise NotImplementedError("The Opentrons backend does not support the CoRe 96.") - async def dispense96(self, dispense: DispensePlate): + async def dispense96(self, dispense: Union[DispensePlate, DispenseContainer]): raise NotImplementedError("The Opentrons backend does not support the CoRe 96.") async def move_resource(self, move: Move): diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py b/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py index fb307d206f..b5a8b1da81 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py @@ -19,11 +19,11 @@ def _is_python_3_10(): def _mock_define(lw): return { "data": { - "definitionUri": f"lw['namespace']/{lw['metadata']['displayName']}/1" + "definitionUri": f'lw["namespace"]/{lw["metadata"]["displayName"]}/1' } } -def _mock_add(load_name, namespace, slot, version, labware_id, display_name): +def _mock_add(load_name, namespace, ot_location, version, labware_id, display_name): # pylint: disable=unused-argument return labware_id diff --git a/pylabrobot/liquid_handling/backends/saver_backend.py b/pylabrobot/liquid_handling/backends/saver_backend.py index b9e3b467fc..fc49b42084 100644 --- a/pylabrobot/liquid_handling/backends/saver_backend.py +++ b/pylabrobot/liquid_handling/backends/saver_backend.py @@ -20,7 +20,10 @@ async def setup(self): self.commands_received = [] async def stop(self): - await super().stop() + pass + + def serialize(self) -> dict: + return {**super().serialize(), "num_channels": self.num_channels} async def send_command(self, command: str, data: Dict[str, Any]): self.commands_received.append({"command": command, "data": data}) @@ -60,12 +63,6 @@ async def dispense96(self, *args, **kwargs): async def move_resource(self, *args, **kwargs): self.commands_received.append({"command": "move_resource", "args": args, "kwargs": kwargs}) - def serialize(self) -> dict: - return { - **super().serialize(), - "num_channels": self.num_channels, - } - # Saver specific methods def clear(self): diff --git a/pylabrobot/liquid_handling/backends/serializing_backend.py b/pylabrobot/liquid_handling/backends/serializing_backend.py index a79c20166e..3528758152 100644 --- a/pylabrobot/liquid_handling/backends/serializing_backend.py +++ b/pylabrobot/liquid_handling/backends/serializing_backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod import sys -from typing import Any, Dict, Optional, List +from typing import Any, Dict, Optional, List, Union from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.resources import Resource @@ -11,8 +11,10 @@ DropTipRack, Aspiration, AspirationPlate, + AspirationContainer, Dispense, DispensePlate, + DispenseContainer, Move, ) from pylabrobot.serializer import serialize @@ -44,12 +46,14 @@ async def send_command( raise NotImplementedError async def setup(self): - await self.send_command(command="setup") await super().setup() + await self.send_command(command="setup") async def stop(self): await self.send_command(command="stop") - await super().stop() + + def serialize(self) -> dict: + return {**super().serialize(), "num_channels": self.num_channels} async def assigned_resource_callback(self, resource: Resource): await self.send_command(command="resource_assigned", data={"resource": resource.serialize(), @@ -116,9 +120,8 @@ async def drop_tips96(self, drop: DropTipRack): await self.send_command(command="drop_tips96", data={ "resource_name": drop.resource.name, "offset": serialize(drop.offset)}) - async def aspirate96(self, aspiration: AspirationPlate): - await self.send_command(command="aspirate96", data={"aspiration": { - "well_names": [well.name for well in aspiration.wells], + async def aspirate96(self, aspiration: Union[AspirationPlate, AspirationContainer]): + data = {"aspiration": { "offset": serialize(aspiration.offset), "volume": aspiration.volume, "flow_rate": serialize(aspiration.flow_rate), @@ -126,11 +129,15 @@ async def aspirate96(self, aspiration: AspirationPlate): "blow_out_air_volume": serialize(aspiration.blow_out_air_volume), "liquids": serialize(aspiration.liquids), "tips": [serialize(tip) for tip in aspiration.tips], - }}) - - async def dispense96(self, dispense: DispensePlate): - await self.send_command(command="dispense96", data={"dispense": { - "well_names": [well.name for well in dispense.wells], + }} + if isinstance(aspiration, AspirationPlate): + data["aspiration"]["well_names"] = [well.name for well in aspiration.wells] + else: + data["aspiration"]["trough"] = aspiration.container.name + await self.send_command(command="aspirate96", data=data) + + async def dispense96(self, dispense: Union[DispensePlate, DispenseContainer]): + data = {"dispense": { "offset": serialize(dispense.offset), "volume": dispense.volume, "flow_rate": serialize(dispense.flow_rate), @@ -138,7 +145,12 @@ async def dispense96(self, dispense: DispensePlate): "blow_out_air_volume": serialize(dispense.blow_out_air_volume), "liquids": serialize(dispense.liquids), "tips": [serialize(tip) for tip in dispense.tips], - }}) + }} + if isinstance(dispense, DispensePlate): + data["dispense"]["well_names"] = [well.name for well in dispense.wells] + else: + data["dispense"]["trough"] = dispense.container.name + await self.send_command(command="dispense96", data=data) async def move_resource(self, move: Move, **backend_kwargs): await self.send_command(command="move", data={"move": { @@ -152,8 +164,9 @@ async def move_resource(self, move: Move, **backend_kwargs): "put_direction": serialize(move.put_direction), }}, **backend_kwargs) - async def prepare_for_manual_channel_operation(self): - await self.send_command(command="prepare_for_manual_channel_operation") + async def prepare_for_manual_channel_operation(self, channel: int): + await self.send_command(command="prepare_for_manual_channel_operation", + data={"channel": channel}) async def move_channel_x(self, channel: int, x: float): await self.send_command(command="move_channel_x", data={"channel": channel, "x": x}) diff --git a/pylabrobot/liquid_handling/backends/serializing_backend_tests.py b/pylabrobot/liquid_handling/backends/serializing_backend_tests.py index e4857f529e..375ed25bd6 100644 --- a/pylabrobot/liquid_handling/backends/serializing_backend_tests.py +++ b/pylabrobot/liquid_handling/backends/serializing_backend_tests.py @@ -6,7 +6,7 @@ STARLetDeck, TIP_CAR_480_A00, PLT_CAR_L5AC_A00, - Cos_96_EZWash, + Cor_96_wellplate_360ul_Fb, STF_L, Coordinate, no_tip_tracking, @@ -29,8 +29,8 @@ async def asyncSetUp(self) -> None: self.deck.assign_child_resource(self.tip_car, rails=1) self.plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - self.plt_car[0] = self.plate = Cos_96_EZWash(name="plate_01", with_lid=True) - self.plt_car[1] = self.other_plate = Cos_96_EZWash(name="plate_02", with_lid=True) + self.plt_car[0] = self.plate = Cor_96_wellplate_360ul_Fb(name="plate_01") + self.plt_car[1] = self.other_plate = Cor_96_wellplate_360ul_Fb(name="plate_02") self.deck.assign_child_resource(self.plt_car, rails=9) self.backend.clear() @@ -46,7 +46,7 @@ async def test_pick_up_tips(self): self.assertEqual(self.backend.sent_commands[0]["data"], { "channels": [{ "resource_name": tip_spot.name, - "offset": None, + "offset": serialize(Coordinate.zero()), "tip": serialize(tip), }], "use_channels": [0]}) @@ -63,7 +63,7 @@ async def test_drop_tips(self): self.assertEqual(self.backend.sent_commands[0]["data"], { "channels": [{ "resource_name": tip_spot.name, - "offset": None, + "offset": serialize(Coordinate.zero()), "tip": serialize(tip), }], "use_channels": [0]}) @@ -72,16 +72,14 @@ async def test_aspirate(self): well.tracker.set_liquids([(None, 10)]) tip = self.tip_rack.get_tip(0) self.lh.update_head_state({0: tip}) - assert self.plate.lid is not None - self.plate.lid.unassign() self.backend.clear() - await self.lh.aspirate([well], vols=10) + await self.lh.aspirate([well], vols=[10]) self.assertEqual(len(self.backend.sent_commands), 1) self.assertEqual(self.backend.sent_commands[0]["command"], "aspirate") self.assertEqual(self.backend.sent_commands[0]["data"], { "channels": [{ "resource_name": well.name, - "offset": None, + "offset": serialize(Coordinate.zero()), "tip": tip.serialize(), "volume": 10, "flow_rate": None, @@ -94,17 +92,15 @@ async def test_dispense(self): wells = self.plate["A1"] tip = self.tip_rack.get_tip(0) self.lh.update_head_state({0: tip}) - assert self.plate.lid is not None - self.plate.lid.unassign() self.backend.clear() with no_volume_tracking(): - await self.lh.dispense(wells, vols=10) + await self.lh.dispense(wells, vols=[10]) self.assertEqual(len(self.backend.sent_commands), 1) self.assertEqual(self.backend.sent_commands[0]["command"], "dispense") self.assertEqual(self.backend.sent_commands[0]["data"], { "channels": [{ "resource_name": wells[0].name, - "offset": None, + "offset": serialize(Coordinate.zero()), "tip": tip.serialize(), "volume": 10, "flow_rate": None, @@ -137,8 +133,6 @@ async def test_aspirate96(self): self.backend.clear() tips = [channel.get_tip() for channel in self.lh.head96.values()] - assert self.plate.lid is not None - self.plate.lid.unassign() self.backend.clear() await self.lh.aspirate96(self.plate, volume=10) self.assertEqual(len(self.backend.sent_commands), 1) @@ -186,7 +180,7 @@ async def test_move(self): "intermediate_locations": [], "resource_offset": serialize(Coordinate.zero()), "destination_offset": serialize(Coordinate.zero()), - "pickup_distance_from_top": 13.2, + "pickup_distance_from_top": 9.87, "get_direction": "FRONT", "put_direction": "FRONT", }}) diff --git a/pylabrobot/liquid_handling/backends/tecan/EVO.py b/pylabrobot/liquid_handling/backends/tecan/EVO.py index 669bb24efb..74e147f13d 100644 --- a/pylabrobot/liquid_handling/backends/tecan/EVO.py +++ b/pylabrobot/liquid_handling/backends/tecan/EVO.py @@ -7,7 +7,8 @@ from abc import ABCMeta, abstractmethod from typing import Dict, List, Optional, Tuple, Sequence, TypeVar, Union -from pylabrobot.liquid_handling.backends.USBBackend import USBBackend +from pylabrobot.machines.backends import USBBackend +from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend from pylabrobot.liquid_handling.liquid_classes.tecan import TecanLiquidClass, get_liquid_class from pylabrobot.liquid_handling.backends.tecan.errors import TecanError, error_code_to_exception from pylabrobot.liquid_handling.standard import ( @@ -17,8 +18,10 @@ DropTipRack, Aspiration, AspirationPlate, + AspirationContainer, Dispense, DispensePlate, + DispenseContainer, Move ) from pylabrobot.resources import ( @@ -34,7 +37,7 @@ T = TypeVar("T") -class TecanLiquidHandler(USBBackend, metaclass=ABCMeta): +class TecanLiquidHandler(LiquidHandlerBackend, USBBackend, metaclass=ABCMeta): """ Abstract base class for Tecan liquid handling robot backends. """ @@ -53,12 +56,14 @@ def __init__( read_timeout: The timeout for reading from the Tecan machine in seconds. """ - super().__init__( + USBBackend.__init__( + self, packet_read_timeout=packet_read_timeout, read_timeout=read_timeout, write_timeout=write_timeout, id_vendor=0x0C47, id_product=0x4000) + LiquidHandlerBackend.__init__(self) self._cache: Dict[str, List[Optional[int]]] = {} @@ -142,6 +147,10 @@ async def send_command( resp = self.read(timeout=read_timeout) return self.parse_response(resp) + async def setup(self): + await LiquidHandlerBackend.setup(self) + await USBBackend.setup(self) + class EVO(TecanLiquidHandler): """ @@ -308,10 +317,7 @@ async def aspirate( tip_type=op.tip.tip_type ) if isinstance(op.tip, TecanTip) else None for op in ops] - for op, tlc in zip(ops, tecan_liquid_classes): - op.volume = tlc.compute_corrected_volume(op.volume) if tlc is not None else op.volume - - ys = int(ops[0].resource.get_size_y() * 10) + ys = int(ops[0].resource.get_absolute_size_y() * 10) zadd: List[Optional[int]] = [0] * self.num_channels for i, channel in enumerate(use_channels): par = ops[i].resource.parent @@ -388,7 +394,7 @@ async def dispense( """ x_positions, y_positions, z_positions = self._liha_positions(ops, use_channels) - ys = int(ops[0].resource.get_size_y() * 10) + ys = int(ops[0].resource.get_absolute_size_y() * 10) tecan_liquid_classes = [ get_liquid_class( @@ -397,11 +403,6 @@ async def dispense( tip_type=op.tip.tip_type ) if isinstance(op.tip, TecanTip) else None for op in ops] - for op, tlc in zip(ops, tecan_liquid_classes): - op.volume = tlc.compute_corrected_volume(op.volume) + \ - tlc.aspirate_lag_volume + tlc.aspirate_tag_volume \ - if tlc is not None else op.volume - x, _ = self._first_valid(x_positions) y, yi = self._first_valid(y_positions) assert x is not None and y is not None @@ -434,7 +435,7 @@ async def pick_up_tips( x_positions, y_positions, _ = self._liha_positions(ops, use_channels) # move channels - ys = int(ops[0].resource.get_size_y() * 10) + ys = int(ops[0].resource.get_absolute_size_y() * 10) x, _ = self._first_valid(x_positions) y, yi = self._first_valid(y_positions) assert x is not None and y is not None @@ -494,10 +495,10 @@ async def pick_up_tips96(self, pickup: PickupTipRack): async def drop_tips96(self, drop: DropTipRack): raise NotImplementedError() - async def aspirate96(self, aspiration: AspirationPlate): + async def aspirate96(self, aspiration: Union[AspirationPlate, AspirationContainer]): raise NotImplementedError() - async def dispense96(self, dispense: DispensePlate): + async def dispense96(self, dispense: Union[DispensePlate, DispenseContainer]): raise NotImplementedError() async def move_resource(self, move: Move): @@ -508,7 +509,7 @@ async def move_resource(self, move: Move): z_range = await self.roma.report_z_param(5) x, y, z = self._roma_positions(move.resource, move.resource.get_absolute_location(), z_range) - h = int(move.resource.get_size_y() * 10) + h = int(move.resource.get_absolute_size_y() * 10) xt, yt, zt = self._roma_positions(move.resource, move.destination, z_range) # move to resource @@ -715,7 +716,8 @@ def _aspirate_action( assert tlc is not None and z is not None sep[channel] = int(tlc.aspirate_speed * 12) # 6? ssz[channel] = round(z * tlc.aspirate_speed / ops[i].volume) - mtr[channel] = round(ops[i].volume * 6) # 3? + volume = tlc.compute_corrected_volume(ops[i].volume) + mtr[channel] = round(volume * 6) # 3? ssz_r[channel] = int(tlc.aspirate_retract_speed * 10) return ssz, sep, stz, mtr, ssz_r @@ -746,7 +748,9 @@ def _dispense_action( sep[channel] = int(tlc.dispense_speed * 12) # 6? spp[channel] = int(tlc.dispense_breakoff * 12) # 6? stz[channel] = 0 - mtr[channel] = -round(ops[i].volume * 6) # 3? + volume = tlc.compute_corrected_volume(ops[i].volume) + tlc.aspirate_lag_volume + \ + tlc.aspirate_tag_volume + mtr[channel] = -round(volume * 6) # 3? return sep, spp, stz, mtr @@ -769,7 +773,7 @@ def _roma_positions( or par.roma_z_travel is None or par.roma_z_end is None: raise ValueError(f"Operation is not supported by resource {par}.") x_position = int((offset.x - 100)* 10 + par.roma_x) - y_position = int((347.1 - (offset.y + resource.get_size_y())) * 10 + par.roma_y) + y_position = int((347.1 - (offset.y + resource.get_absolute_size_y())) * 10 + par.roma_y) z_positions = { "safe": z_range - int(par.roma_z_safe), "travel": z_range - int(par.roma_z_travel - offset.z * 10), diff --git a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py b/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py index efd5aadc70..25d902d46c 100644 --- a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py +++ b/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py @@ -6,7 +6,6 @@ from pylabrobot.liquid_handling.backends.tecan.EVO import EVO, LiHa, RoMa from pylabrobot.liquid_handling.standard import ( Pickup, - # Drop, Aspiration, Dispense, Move, @@ -70,7 +69,7 @@ async def send_command(module, command, params=None): # pylint: disable=unused-a async def test_pick_up_tip(self): op = Pickup( resource=self.tr.get_item("A1"), - offset=None, + offset=Coordinate.zero(), tip=self.tr.get_tip("A1") ) await self.evo.pick_up_tips([op], use_channels=[0]) @@ -99,7 +98,7 @@ async def test_pick_up_tip(self): async def test_aspirate(self): op = Aspiration( resource=self.plate.get_item("A1"), - offset=None, + offset=Coordinate.zero(), tip=self.tr.get_tip("A1"), volume=100, flow_rate=100, @@ -123,11 +122,11 @@ async def test_aspirate(self): call(module="C5", command="SML", params=[985, 2000, 2000, 2000, 2000, 2000, 2000, 2000]), call(module="C5", command="SBL", params=[20, None, None, None, None, None, None, None]), call(module="C5", command="SHZ", params=[1455, 1455, 1455, 1455, 1455, 1455, 1455, 1455]), - call(module="C5", command="MDT", params=[1, None, None, None, 31, 0, 0, 0, 0, 0, 0, 0]), + call(module="C5", command="MDT", params=[1, None, None, None, 30, 0, 0, 0, 0, 0, 0, 0]), call(module="C5", command="SHZ", params=[2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000]), call(module="C5", command="SSZ", params=[30, None, None, None, None, None, None, None]), call(module="C5", command="SEP", params=[1200, None, None, None, None, None, None, None]), - call(module="C5", command="STZ", params=[-31, None, None, None, None, None, None, None]), + call(module="C5", command="STZ", params=[-30, None, None, None, None, None, None, None]), call(module="C5", command="MTR", params=[626, None, None, None, None, None, None, None]), call(module="C5", command="SSZ", params=[200, None, None, None, None, None, None, None]), call(module="C5", command="MAZ", params=[1375, None, None, None, None, None, None, None]), @@ -139,7 +138,7 @@ async def test_aspirate(self): async def test_dispense(self): op = Dispense( resource=self.plate.get_item("A1"), - offset=None, + offset=Coordinate.zero(), tip=self.tr.get_tip("A1"), volume=100, flow_rate=100, diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/liquid_handling/liquid_handler.py index 697dfbcb45..a6d653984c 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/liquid_handling/liquid_handler.py @@ -7,15 +7,16 @@ import inspect import json import logging -import numbers import threading from typing import Any, Callable, Dict, Union, Optional, List, Sequence, Set, Tuple, Protocol, cast import warnings -from pylabrobot.machine import Machine, need_setup_finished +from pylabrobot.machines.machine import Machine, need_setup_finished from pylabrobot.liquid_handling.strictness import Strictness, get_strictness from pylabrobot.liquid_handling.errors import ChannelizedError +from pylabrobot.resources.errors import HasTipError from pylabrobot.plate_reading import PlateReader +from pylabrobot.resources.errors import CrossContaminationError from pylabrobot.resources import ( Container, Deck, @@ -23,20 +24,24 @@ ResourceStack, Coordinate, CarrierSite, + PlateCarrierSite, Lid, MFXModule, Plate, + PlateAdapter, Tip, TipRack, TipSpot, Trash, Well, TipTracker, + VolumeTracker, does_tip_tracking, - does_volume_tracking + does_volume_tracking, + does_cross_contamination_tracking ) from pylabrobot.resources.liquid import Liquid -from pylabrobot.utils.list import expand +from pylabrobot.tilting.tilter import Tilter from .backends import LiquidHandlerBackend from .standard import ( @@ -46,14 +51,32 @@ DropTipRack, Aspiration, AspirationPlate, + AspirationContainer, Dispense, DispensePlate, + DispenseContainer, Move, GripDirection ) + logger = logging.getLogger("pylabrobot") +def check_contaminated(liquid_history_tip, liquid_history_well): + """Helper function used to check if adding a liquid to the container + would result in cross contamination""" + return not liquid_history_tip.issubset(liquid_history_well) and len(liquid_history_tip) > 0 + +def check_updatable(src_tracker: VolumeTracker, dest_tracker: VolumeTracker): + """Helper function used to check if it is possible to update the + liquid_history of src based on contents of dst""" + return not src_tracker.is_cross_contamination_tracking_disabled and \ + not dest_tracker.is_cross_contamination_tracking_disabled + + +class BlowOutVolumeError(Exception): + ... + class LiquidHandler(Machine): """ @@ -105,17 +128,20 @@ def __init__(self, backend: LiquidHandlerBackend, deck: Deck): self.head96: Dict[int, TipTracker] = {} self._default_use_channels: Optional[List[int]] = None + self._blow_out_air_volume: Optional[List[Optional[float]]] = None + # assign deck as only child resource, and set location of self to origin. self.location = Coordinate.zero() super().assign_child_resource(deck, location=deck.location or Coordinate.zero()) - async def setup(self): + async def setup(self, **backend_kwargs): """ Prepare the robot for use. """ if self.setup_finished: raise RuntimeError("The setup has already finished. See `LiquidHandler.stop`.") - await super().setup() + self.backend.set_deck(self.deck) + await super().setup(**backend_kwargs) self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)} self.head96 = {c: TipTracker(thing=f"Channel {c}") for c in range(96)} @@ -171,6 +197,7 @@ def callback(*args, **kwargs): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(func(*args, **kwargs)) + loop.close() t = threading.Thread(target=callback, args=args, kwargs=kwargs) t.start() @@ -186,26 +213,8 @@ def _send_unassigned_resource_to_backend(self, resource: Resource): information to the backend. """ self._run_async_in_thread(self.backend.unassigned_resource_callback, resource.name) - def unassign_resource(self, resource: Union[str, Resource]): # TODO: remove this. - """ Unassign an assigned resource. - - Args: - resource: The resource to unassign. - - Raises: - KeyError: If the resource is not currently assigned to this liquid handler. - """ - - if isinstance(resource, Resource): - resource = resource.name - - r = self.deck.get_resource(resource) - if r is None: - raise KeyError(f"Resource '{resource}' is not assigned to this liquid handler.") - r.unassign() - def summary(self): - """ Prints a string summary of the deck layout. """ + """ Prints a string summary of the deck layout. """ print(self.deck.summary()) @@ -275,8 +284,10 @@ def _check_args( if len(missing) > 0: raise TypeError(f"Missing arguments to backend.{method.__name__}: {missing}") - extra = backend_kws - set(args.keys()) + if len(vars_keyword) > 0: + return set() # no extra arguments if the method accepts **kwargs + extra = backend_kws - set(args.keys()) if len(extra) > 0 and len(vars_keyword) == 0: if strictness == Strictness.STRICT: raise TypeError(f"Extra arguments to backend.{method.__name__}: {extra}") @@ -298,7 +309,7 @@ async def pick_up_tips( self, tip_spots: List[TipSpot], use_channels: Optional[List[int]] = None, - offsets: Optional[Union[Coordinate, List[Optional[Coordinate]]]] = None, + offsets: Optional[List[Coordinate]] = None, **backend_kwargs ): """ Pick up tips from a resource. @@ -331,8 +342,8 @@ async def pick_up_tips( tip_spots: List of tip spots to pick up tips from. use_channels: List of channels to use. Index from front to back. If `None`, the first `len(channels)` channels will be used. - offsets: List of offsets for each channel, a translation that will be applied to the tip - drop location. If `None`, no offset will be applied. + offsets: List of offsets, one for each channel: a translation that will be applied to the tip + drop location. backend_kwargs: Additional keyword arguments for the backend, optional. Raises: @@ -345,8 +356,11 @@ async def pick_up_tips( NoTipError: If a spot does not have a tip. """ + not_tip_spots = [ts for ts in tip_spots if not isinstance(ts, TipSpot)] + if len(not_tip_spots) > 0: + raise TypeError(f"Resources must be `TipSpot`s, got {not_tip_spots}") + # fix arguments - offsets = expand(offsets, len(tip_spots)) if use_channels is None: if self._default_use_channels is None: use_channels = list(range(len(tip_spots))) @@ -354,6 +368,9 @@ async def pick_up_tips( use_channels = self._default_use_channels tips = [tip_spot.get_tip() for tip_spot in tip_spots] + # expand default arguments + offsets = offsets or [Coordinate.zero()] * len(tip_spots) + # checks self._assert_resources_exist(tip_spots) self._make_sure_channels_exist(use_channels) @@ -366,10 +383,10 @@ async def pick_up_tips( # queue operations on the trackers for channel, op in zip(use_channels, pickups): + if self.head[channel].has_tip: + raise HasTipError("Channel has tip") if does_tip_tracking() and not op.resource.tracker.is_disabled: op.resource.tracker.remove_tip() - if not does_tip_tracking() and self.head[channel].has_tip: - self.head[channel].remove_tip() # override the tip if a tip exists self.head[channel].add_tip(op.tip, origin=op.resource, commit=False) # fix the backend kwargs @@ -409,9 +426,9 @@ async def pick_up_tips( @need_setup_finished async def drop_tips( self, - tip_spots: List[Union[TipSpot, Resource]], + tip_spots: List[Union[TipSpot, Trash]], use_channels: Optional[List[int]] = None, - offsets: Optional[Union[Coordinate, List[Optional[Coordinate]]]] = None, + offsets: Optional[List[Coordinate]] = None, allow_nonzero_volume: bool = False, **backend_kwargs ): @@ -434,11 +451,11 @@ async def drop_tips( ... ) Args: - tips: Tip resource locations to drop to. + tip_spots: Tip resource locations to drop to. use_channels: List of channels to use. Index from front to back. If `None`, the first `len(channels)` channels will be used. - offsets: List of offsets for each channel, a translation that will be applied to the tip - pickup location. If `None`, no offset will be applied. + offsets: List of offsets, one for each channel, a translation that will be applied to the tip + drop location. If `None`, no offset will be applied. allow_nonzero_volume: If `True`, the tip will be dropped even if its volume is not zero (there is liquid in the tip). If `False`, a RuntimeError will be raised if the tip has nonzero volume. @@ -457,9 +474,11 @@ async def drop_tips( HasTipError: If a spot already has a tip. """ + not_tip_spots = [ts for ts in tip_spots if not isinstance(ts, (TipSpot, Trash))] + if len(not_tip_spots) > 0: + raise TypeError(f"Resources must be `TipSpot`s or Trash, got {not_tip_spots}") # fix arguments - offsets = expand(offsets, len(tip_spots)) if use_channels is None: if self._default_use_channels is None: use_channels = list(range(len(tip_spots))) @@ -472,6 +491,9 @@ async def drop_tips( raise RuntimeError(f"Cannot drop tip with volume {tip.tracker.get_used_volume()}") tips.append(tip) + # expand default arguments + offsets = offsets or [Coordinate.zero()] * len(tip_spots) + # checks self._assert_resources_exist(tip_spots) self._make_sure_channels_exist(use_channels) @@ -524,7 +546,7 @@ async def drop_tips( **backend_kwargs, ) - async def return_tips(self, **backend_kwargs): + async def return_tips(self, use_channels: Optional[list[int]] = None, **backend_kwargs): """ Return all tips that are currently picked up to their original place. Examples: @@ -544,6 +566,8 @@ async def return_tips(self, **backend_kwargs): channels: List[int] = [] for channel, tracker in self.head.items(): + if use_channels is not None and channel not in use_channels: + continue if tracker.has_tip: origin = tracker.get_tip_origin() if origin is None: @@ -589,7 +613,7 @@ async def discard_tips( raise RuntimeError("No tips have been picked up and no channels were specified.") trash = self.deck.get_trash_area() - offsets = list(reversed(trash.centers(yn=n))) + offsets = [c - trash.center() for c in reversed(trash.centers(yn=n))] # offset is wrt center return await self.drop_tips( tip_spots=[trash]*n, @@ -598,16 +622,22 @@ async def discard_tips( allow_nonzero_volume=allow_nonzero_volume, **backend_kwargs) + def _check_containers(self, resources: Sequence[Resource]): + """ Checks that all resources are containers. """ + not_containers = [r for r in resources if not isinstance(r, Container)] + if len(not_containers) > 0: + raise TypeError(f"Resources must be `Container`s, got {not_containers}") + @need_setup_finished async def aspirate( self, - resources: Union[Container, Sequence[Container]], - vols: Union[List[float], float], + resources: Sequence[Container], + vols: List[float], use_channels: Optional[List[int]] = None, - flow_rates: Optional[Union[float, List[Optional[float]]]] = None, - offsets: Union[Optional[Coordinate], Sequence[Optional[Coordinate]]] = None, - liquid_height: Union[Optional[float], List[Optional[float]]] = None, - blow_out_air_volume: Union[Optional[float], List[Optional[float]]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, **backend_kwargs ): """ Aspirate liquid from the specified wells. @@ -648,7 +678,7 @@ async def aspirate( `len(wells)` channels will be used. flow_rates: the aspiration speed. In ul/s. If `None`, the backend default will be used. offsets: List of offsets for each channel, a translation that will be applied to the - aspiration location. If `None`, no offset will be applied. + aspiration location. liquid_height: The height of the liquid in the well wrt the bottom, in mm. blow_out_air_volume: The volume of air to aspirate after the liquid, in ul. If `None`, the backend default will be used. @@ -660,60 +690,43 @@ async def aspirate( ValueError: If all channels are `None`. """ - # Start with computing the locations of the aspirations. Can either be a single resource, in - # which case all channels will aspirate from there, or a list of resources. - if isinstance(resources, Resource): # if single resource, space channels evenly - if use_channels is None: - if self._default_use_channels is None: - if isinstance(vols, list): - use_channels = list(range(len(vols))) - else: - use_channels = [0] - else: - use_channels = self._default_use_channels - - self._make_sure_channels_exist(use_channels) - - n = len(use_channels) - - # If offsets is supplied, make sure it is a list of the correct length. If it is not in this - # format, raise an error. If it is not supplied, make it a list of the correct length by - # spreading channels across the resource evenly. - center_offsets = list(reversed(resources.centers(yn=n, zn=0))) - if offsets is not None: - if not isinstance(offsets, list) or len(offsets) != n: - raise ValueError("Number of offsets must match number of channels used when aspirating " - "from a resource.") - offsets = [o + co for o, co in zip(offsets, center_offsets)] - else: - offsets = center_offsets + self._check_containers(resources) - resources = [resources] * n - else: - if len(resources) == 0: - raise ValueError("No channels specified") - self._assert_resources_exist(resources) - n = len(resources) + use_channels = use_channels or self._default_use_channels or list(range(len(resources))) - for resource in resources: - if isinstance(resource.parent, Plate) and resource.parent.has_lid(): - raise ValueError("Aspirating from plate with lid") + # expand default arguments + offsets = offsets or [Coordinate.zero()] * len(use_channels) + flow_rates = flow_rates or [None] * len(use_channels) + liquid_height = liquid_height or [None] * len(use_channels) + blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) - if use_channels is None: - use_channels = list(range(len(resources))) + # Convert everything to floats to handle exotic number types + vols = [float(v) for v in vols] + flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] + liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] + blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] - self._make_sure_channels_exist(use_channels) + self._blow_out_air_volume = blow_out_air_volume + tips = [self.head[channel].get_tip() for channel in use_channels] - offsets = expand(offsets, n) + # Checks + for resource in resources: + if isinstance(resource.parent, Plate) and resource.parent.has_lid(): + raise ValueError("Aspirating from a well with a lid is not supported.") - # expand the rest of the arguments - vols = expand(vols, n) - flow_rates = expand(flow_rates, n) - liquid_height = expand(liquid_height, n) - blow_out_air_volume = expand(blow_out_air_volume, n) - tips = [self.head[channel].get_tip() for channel in use_channels] + self._make_sure_channels_exist(use_channels) + assert len(resources) == len(vols) == len(offsets) == len(flow_rates) == len(liquid_height) - assert len(vols) == len(offsets) == len(flow_rates) == len(liquid_height) + # If the user specified a single resource, but multiple channels to use, we will assume they + # want to space the channels evenly across the resource. Note that offsets are relative to the + # center of the resource. + if len(set(resources)) == 1: + resource = resources[0] + n = len(use_channels) + resources = [resource] * len(use_channels) + centers = list(reversed(resource.centers(yn=n, zn=0))) + centers = [c - resource.center() for c in centers] # offset is wrt center + offsets = [c + o for c, o in zip(centers, offsets)] # user-defined # liquid(s) for each channel. If volume tracking is disabled, use None as the liquid. liquids: List[List[Tuple[Optional[Liquid], float]]] = [] @@ -735,6 +748,14 @@ async def aspirate( if does_volume_tracking(): if not op.resource.tracker.is_disabled: op.resource.tracker.remove_liquid(op.volume) + + # Cross contamination check + if does_cross_contamination_tracking(): + if check_contaminated(op.tip.tracker.liquid_history, op.resource.tracker.liquid_history): + raise CrossContaminationError( + f"Attempting to aspirate {next(reversed(op.liquids))[0]} with a tip contaminated " + f"with {op.tip.tracker.liquid_history}.") + for liquid, volume in reversed(op.liquids): op.tip.tracker.add_liquid(liquid=liquid, volume=volume) @@ -760,7 +781,7 @@ async def aspirate( if does_volume_tracking(): if not op.resource.tracker.is_disabled: (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - (self.head[channel].commit if success else self.head[channel].rollback)() + (self.head[channel].get_tip().tracker.commit if success else self.head[channel].rollback)() # trigger callback self._trigger_callback( @@ -775,13 +796,13 @@ async def aspirate( @need_setup_finished async def dispense( self, - resources: Union[Container, Sequence[Container]], - vols: Union[List[float], float], + resources: Sequence[Container], + vols: List[float], use_channels: Optional[List[int]] = None, - flow_rates: Optional[Union[float, List[Optional[float]]]] = None, - offsets: Union[Optional[Coordinate], Sequence[Optional[Coordinate]]] = None, - liquid_height: Union[Optional[float], List[Optional[float]]] = None, - blow_out_air_volume: Union[Optional[float], List[Optional[float]]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, **backend_kwargs ): """ Dispense liquid to the specified channels. @@ -822,7 +843,7 @@ async def dispense( `len(channels)` channels will be used. flow_rates: the flow rates, in ul/s. If `None`, the backend default will be used. offsets: List of offsets for each channel, a translation that will be applied to the - dispense location. If `None`, no offset will be applied. + dispense location. liquid_height: The height of the liquid in the well wrt the bottom, in mm. blow_out_air_volume: The volume of air to dispense after the liquid, in ul. If `None`, the backend default will be used. @@ -836,65 +857,59 @@ async def dispense( ValueError: If all channels are `None`. """ - # Start with computing the locations of the dispenses. Can either be a single resource, in - # which case all channels will dispense to there, or a list of resources. - if isinstance(resources, Resource): # if single resource, space channels evenly - if use_channels is None: - if self._default_use_channels is None: - if isinstance(vols, list): - use_channels = list(range(len(vols))) - else: - use_channels = [0] - else: - use_channels = self._default_use_channels + # If the user specified a single resource, but multiple channels to use, we will assume they + # want to space the channels evenly across the resource. Note that offsets are relative to the + # center of the resource. - self._make_sure_channels_exist(use_channels) + self._check_containers(resources) - n = len(use_channels) - - # If offsets is supplied, make sure it is a list of the correct length. If it is not in this - # format, raise an error. If it is not supplied, make it a list of the correct length by - # spreading channels across the resource evenly. - center_offsets = list(reversed(resources.centers(yn=n, zn=0))) - if offsets is not None: - if not isinstance(offsets, list) or len(offsets) != n: - raise ValueError("Number of offsets must match number of channels used when dispensing " - "to a resource.") - offsets = [o + co for o, co in zip(offsets, center_offsets)] - else: - offsets = center_offsets + use_channels = use_channels or self._default_use_channels or list(range(len(resources))) - resources = [resources] * n - else: - if len(resources) == 0: - raise ValueError("No channels specified") - self._assert_resources_exist(resources) - n = len(resources) + # expand default arguments + offsets = offsets or [Coordinate.zero()] * len(use_channels) + flow_rates = flow_rates or [None] * len(use_channels) + liquid_height = liquid_height or [None] * len(use_channels) + blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) - for resource in resources: - if isinstance(resource.parent, Plate) and resource.parent.has_lid(): - raise ValueError("Dispensing to plate with lid") + # Convert everything to floats to handle exotic number types + vols = [float(v) for v in vols] + flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] + liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] + blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] - if use_channels is None: - use_channels = list(range(len(resources))) + # If the user specified a single resource, but multiple channels to use, we will assume they + # want to space the channels evenly across the resource. Note that offsets are relative to the + # center of the resource. + if len(set(resources)) == 1: + resource = resources[0] + n = len(use_channels) + resources = [resource] * len(use_channels) + centers = list(reversed(resource.centers(yn=n, zn=0))) + centers = [c - resource.center() for c in centers] # offset is wrt center + offsets = [c + o for c, o in zip(centers, offsets)] # user-defined - self._make_sure_channels_exist(use_channels) + tips = [self.head[channel].get_tip() for channel in use_channels] - offsets = expand(offsets, n) + # Check the blow out air volume with what was aspirated + if does_volume_tracking(): + if any(bav is not None and bav != 0.0 for bav in blow_out_air_volume): + if self._blow_out_air_volume is None: + raise BlowOutVolumeError("No blowout volume was aspirated.") + for requested_bav, done_bav in zip(blow_out_air_volume, self._blow_out_air_volume): + if requested_bav is not None and done_bav is not None and requested_bav > done_bav: + raise BlowOutVolumeError("Blowout volume is larger than aspirated volume") - # expand the rest of the arguments - vols = expand(vols, n) - flow_rates = expand(flow_rates, n) - liquid_height = expand(liquid_height, n) - blow_out_air_volume = expand(blow_out_air_volume, n) - tips = [self.head[channel].get_tip() for channel in use_channels] + for resource in resources: + if isinstance(resource.parent, Plate) and resource.parent.has_lid(): + raise ValueError("Dispensing to plate with lid") assert len(vols) == len(offsets) == len(flow_rates) == len(liquid_height) # liquid(s) for each channel. If volume tracking is disabled, use None as the liquid. if does_volume_tracking(): + channels = [self.head[channel] for channel in use_channels] liquids = [c.get_tip().tracker.get_liquids(top_volume=vol) - for c, vol in zip(self.head.values(), vols)] + for c, vol in zip(channels, vols)] else: liquids = [[(None, vol)] for vol in vols] @@ -909,6 +924,10 @@ async def dispense( for op in dispenses: if does_volume_tracking(): if not op.resource.tracker.is_disabled: + # Update the liquid history of the tip to reflect new liquid + if check_updatable(op.tip.tracker, op.resource.tracker): + op.tip.tracker.liquid_history.update(op.resource.tracker.liquid_history) + for liquid, volume in op.liquids: op.resource.tracker.add_liquid(liquid=liquid, volume=volume) op.tip.tracker.remove_liquid(op.volume) @@ -927,7 +946,7 @@ async def dispense( error = e # determine which channels were successful - successes = [error is not None] * len(dispenses) + successes = [error is None] * len(dispenses) if error is not None and isinstance(error, ChannelizedError): successes = [channel_idx not in error.errors for channel_idx in use_channels] @@ -936,11 +955,14 @@ async def dispense( if does_volume_tracking(): if not op.resource.tracker.is_disabled: (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - (self.head[channel].commit if success else self.head[channel].rollback)() + (self.head[channel].get_tip().tracker.commit if success else self.head[channel].rollback)() + + if any(bav is not None for bav in blow_out_air_volume): + self._blow_out_air_volume = None # trigger callback self._trigger_callback( - "aspirate", + "dispense", liquid_handler=self, operations=dispenses, use_channels=use_channels, @@ -951,7 +973,7 @@ async def dispense( async def transfer( self, source: Well, - targets: Union[Well, List[Well]], + targets: List[Well], source_vol: Optional[float] = None, ratios: Optional[List[float]] = None, target_vols: Optional[List[float]] = None, @@ -997,12 +1019,6 @@ async def transfer( RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. """ - if isinstance(targets, Well): - targets = [targets] - - if isinstance(dispense_flow_rates, numbers.Rational): - dispense_flow_rates = [dispense_flow_rates] * len(targets) - if target_vols is not None: if ratios is not None: raise TypeError("Cannot specify ratios and target_vols at the same time") @@ -1025,7 +1041,7 @@ async def transfer( for target, vol in zip(targets, target_vols): await self.dispense( resources=[target], - vols=vol, + vols=[vol], flow_rates=dispense_flow_rates, use_channels=[0], **backend_kwargs) @@ -1063,7 +1079,8 @@ async def pick_up_tips96( self, tip_rack: TipRack, offset: Coordinate = Coordinate.zero(), - **backend_kwargs): + **backend_kwargs + ): """ Pick up tips using the 96 head. This will pick up 96 tips. Examples: @@ -1077,6 +1094,11 @@ async def pick_up_tips96( backend_kwargs: Additional keyword arguments for the backend, optional. """ + if not isinstance(tip_rack, TipRack): + raise TypeError(f"Resource must be a TipRack, got {tip_rack}") + if not tip_rack.num_items == 96: + raise ValueError("Tip rack must have 96 tips") + extras = self._check_args(self.backend.pick_up_tips96, backend_kwargs, default={"pickup"}) for extra in extras: del backend_kwargs[extra] @@ -1147,6 +1169,11 @@ async def drop_tips96( backend_kwargs: Additional keyword arguments for the backend, optional. """ + if not isinstance(resource, (TipRack, Trash)): + raise TypeError(f"Resource must be a TipRack or Trash, got {resource}") + if isinstance(resource, TipRack) and not resource.num_items == 96: + raise ValueError("Tip rack must have 96 tips") + extras = self._check_args(self.backend.drop_tips96, backend_kwargs, default={"drop"}) for extra in extras: del backend_kwargs[extra] @@ -1267,82 +1294,122 @@ async def discard_tips96(self, allow_nonzero_volume: bool = True, **backend_kwar async def aspirate96( self, - resource: Union[Plate, List[Well]], + resource: Union[Plate, Container, List[Well]], volume: float, offset: Coordinate = Coordinate.zero(), flow_rate: Optional[float] = None, blow_out_air_volume: Optional[float] = None, **backend_kwargs ): - """ Aspirate from all wells in a plate. + """ Aspirate from all wells in a plate or from a container of a sufficient size. Examples: - Aspirate an entire 96 well plate: + Aspirate an entire 96 well plate or a container of sufficient size: >>> lh.aspirate96(plate, volume=50) + >>> lh.aspirate96(container, volume=50) Args: - resource: Resource name or resource object. - pattern: Either a list of lists of booleans where inner lists represent rows and outer lists - represent columns, or a string representing a range of positions. Default all. - volume: The volume to aspirate from each well. - flow_rate: The flow rate to use when aspirating, in ul/s. If `None`, the backend default - will be used. - blow_out_air_volume: The volume of air to aspirate after the liquid, in ul. If `None`, the + resource (Union[Plate, Container, List[Well]]): Resource object or list of wells. + volume (float): The volume to aspirate through each channel + offset (Coordinate): Adjustment to where the 96 head should go to aspirate relative to where + the plate or container is defined to be. Defaults to Coordinate.zero(). + flow_rate ([Optional[float]]): The flow rate to use when aspirating, in ul/s. If `None`, the backend default will be used. + blow_out_air_volume ([Optional[float]]): The volume of air to aspirate after the liquid, in + ul. If `None`, the backend default will be used. backend_kwargs: Additional keyword arguments for the backend, optional. """ + if not (isinstance(resource, (Plate, Container)) or \ + (isinstance(resource, list) and all(isinstance(w, Well) for w in resource))): + raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") + extras = self._check_args(self.backend.aspirate96, backend_kwargs, default={"aspiration"}) for extra in extras: del backend_kwargs[extra] tips = [channel.get_tip() for channel in self.head96.values()] - - if isinstance(resource, Plate): - if resource.has_lid(): - raise ValueError("Aspirating from plate with lid") - wells = resource.get_all_items() + all_liquids: List[List[Tuple[Optional[Liquid], float]]] = [] + aspiration: Union[AspirationPlate, AspirationContainer] + + # Convert everything to floats to handle exotic number types + volume = float(volume) + flow_rate = float(flow_rate) if flow_rate is not None else None + blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None + + if isinstance(resource, Container): + if resource.get_absolute_size_x() < 108.0 or \ + resource.get_absolute_size_y() < 70.0: # TODO: analyze as attr + raise ValueError("Container too small to accommodate 96 head") + + for channel in self.head96.values(): + # superfluous to have append in two places but the type checker is very angry and does not + # understand that Optional[Liquid] (remove_liquid) is the same as None from the first case + liquids: List[Tuple[Optional[Liquid], float]] + if resource.tracker.is_disabled or not does_volume_tracking(): + liquids = [(None, volume)] + all_liquids.append(liquids) + else: + liquids = resource.tracker.remove_liquid(volume=volume) # type: ignore + all_liquids.append(liquids) + + for liquid, vol in reversed(liquids): + channel.get_tip().tracker.add_liquid(liquid=liquid, volume=vol) + + aspiration = AspirationContainer( + container=resource, + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=None, + blow_out_air_volume=blow_out_air_volume, + liquids=cast(List[List[Tuple[Optional[Liquid], float]]], all_liquids) # stupid + ) else: - wells = resource - - # ensure that wells are all in the same plate - plate = wells[0].parent - for well in wells: - if well.parent != plate: - raise ValueError("All wells must be in the same plate") - - if not len(wells) == 96: - raise ValueError(f"aspirate96 expects 96 wells, got {len(wells)}") - - # liquid(s) for each channel. If volume tracking is disabled, use None as the liquid. - all_liquids: List[Sequence[Tuple[Optional[Liquid], float]]] = [] - for well, channel in zip(wells, self.head96.values()): - # superfluous to have append in two places but the type checker is very angry and does not - # understand that Optional[Liquid] (remove_liquid) is the same as None from the first case - if well.tracker.is_disabled or not does_volume_tracking(): - liquids = [(None, volume)] - all_liquids.append(liquids) + if isinstance(resource, Plate): + if resource.has_lid(): + raise ValueError("Aspirating from plate with lid") + wells = resource.get_all_items() else: - liquids = well.tracker.remove_liquid(volume=volume) # type: ignore - all_liquids.append(liquids) - - for liquid, vol in reversed(liquids): - channel.get_tip().tracker.add_liquid(liquid=liquid, volume=vol) - - aspiration_plate = AspirationPlate( - wells=wells, - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=None, - blow_out_air_volume=blow_out_air_volume, - liquids=cast(List[List[Tuple[Optional[Liquid], float]]], all_liquids) # stupid - ) + wells = resource + + # ensure that wells are all in the same plate + plate = wells[0].parent + for well in wells: + if well.parent != plate: + raise ValueError("All wells must be in the same plate") + + if not len(wells) == 96: + raise ValueError(f"aspirate96 expects 96 wells, got {len(wells)}") + + for well, channel in zip(wells, self.head96.values()): + # superfluous to have append in two places but the type checker is very angry and does not + # understand that Optional[Liquid] (remove_liquid) is the same as None from the first case + if well.tracker.is_disabled or not does_volume_tracking(): + liquids = [(None, volume)] + all_liquids.append(liquids) + else: + liquids = well.tracker.remove_liquid(volume=volume) # type: ignore + all_liquids.append(liquids) + + for liquid, vol in reversed(liquids): + channel.get_tip().tracker.add_liquid(liquid=liquid, volume=vol) + + aspiration = AspirationPlate( + wells=wells, + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=None, + blow_out_air_volume=blow_out_air_volume, + liquids=cast(List[List[Tuple[Optional[Liquid], float]]], all_liquids) # stupid + ) try: - await self.backend.aspirate96(aspiration=aspiration_plate, **backend_kwargs) + await self.backend.aspirate96(aspiration=aspiration, **backend_kwargs) except Exception as error: # pylint: disable=broad-except for channel, well in zip(self.head96.values(), wells): if does_volume_tracking() and not well.tracker.is_disabled: @@ -1351,7 +1418,7 @@ async def aspirate96( self._trigger_callback( "aspirate96", liquid_handler=self, - aspiration=aspiration_plate, + aspiration=aspiration, error=error, **backend_kwargs, ) @@ -1363,14 +1430,14 @@ async def aspirate96( self._trigger_callback( "aspirate96", liquid_handler=self, - aspiration=aspiration_plate, + aspiration=aspiration, error=None, **backend_kwargs, ) async def dispense96( self, - resource: Union[Plate, List[Well]], + resource: Union[Plate, Container, List[Well]], volume: float, offset: Coordinate = Coordinate.zero(), flow_rate: Optional[float] = None, @@ -1385,65 +1452,103 @@ async def dispense96( >>> lh.dispense96(plate, volume=50) Args: - resource: Resource name or resource object. - pattern: Either a list of lists of booleans where inner lists represent rows and outer lists - represent columns, or a string representing a range of positions. Default all. - volume: The volume to dispense to each well. - flow_rate: The flow rate to use when aspirating, in ul/s. If `None`, the backend default - will be used. - blow_out_air_volume: The volume of air to dispense after the liquid, in ul. If `None`, the + resource (Union[Plate, Container, List[Well]]): Resource object or list of wells. + volume (float): The volume to dispense through each channel + offset (Coordinate): Adjustment to where the 96 head should go to aspirate relative to where + the plate or container is defined to be. Defaults to Coordinate.zero(). + flow_rate ([Optional[float]]): The flow rate to use when dispensing, in ul/s. If `None`, the backend default will be used. + blow_out_air_volume ([Optional[float]]): The volume of air to dispense after the liquid, in + ul. If `None`, the backend default will be used. backend_kwargs: Additional keyword arguments for the backend, optional. """ + if not (isinstance(resource, (Plate, Container)) or \ + (isinstance(resource, list) and all(isinstance(w, Well) for w in resource))): + raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") + extras = self._check_args(self.backend.dispense96, backend_kwargs, default={"dispense"}) for extra in extras: del backend_kwargs[extra] tips = [channel.get_tip() for channel in self.head96.values()] - - if isinstance(resource, Plate): - if resource.has_lid(): - raise ValueError("Aspirating from plate with lid") - wells = resource.get_all_items() + all_liquids: List[List[Tuple[Optional[Liquid], float]]] = [] + dispense: Union[DispensePlate, DispenseContainer] + + # Convert everything to floats to handle exotic number types + volume = float(volume) + flow_rate = float(flow_rate) if flow_rate is not None else None + blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None + + if isinstance(resource, Container): + if resource.get_absolute_size_x() < 108.0 or \ + resource.get_absolute_size_y() < 70.0: # TODO: analyze as attr + raise ValueError("Container too small to accommodate 96 head") + + for channel in self.head96.values(): + # superfluous to have append in two places but the type checker is very angry and does not + # understand that Optional[Liquid] (remove_liquid) is the same as None from the first case + liquids: List[Tuple[Optional[Liquid], float]] + if resource.tracker.is_disabled or not does_volume_tracking(): + liquids = [(None, volume)] + all_liquids.append(liquids) + else: + liquids = resource.tracker.remove_liquid(volume=volume) # type: ignore + all_liquids.append(liquids) + + for liquid, vol in reversed(liquids): + channel.get_tip().tracker.add_liquid(liquid=liquid, volume=vol) + + dispense = DispenseContainer( + container=resource, + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=None, + blow_out_air_volume=blow_out_air_volume, + liquids=cast(List[List[Tuple[Optional[Liquid], float]]], all_liquids) # stupid + ) else: - wells = resource + if isinstance(resource, Plate): + if resource.has_lid(): + raise ValueError("Aspirating from plate with lid") + wells = resource.get_all_items() + else: + wells = resource - # ensure that wells are all in the same plate - plate = wells[0].parent - for well in wells: - if well.parent != plate: - raise ValueError("All wells must be in the same plate") + # ensure that wells are all in the same plate + plate = wells[0].parent + for well in wells: + if well.parent != plate: + raise ValueError("All wells must be in the same plate") - if not len(wells) == 96: - raise ValueError(f"dispense96 expects 96 wells, got {len(wells)}") + if not len(wells) == 96: + raise ValueError(f"dispense96 expects 96 wells, got {len(wells)}") - # liquid(s) for each channel. If volume tracking is disabled, use None as the liquid. - all_liquids: List[List[Tuple[Optional[Liquid], float]]] = [] - for channel, well in zip(self.head96.values(), wells): - liquids = None # liquids in this well - # even if the volume tracker is disabled, a liquid (None, volume) is added to the list during - # the aspiration command - l = channel.get_tip().tracker.remove_liquid(volume=volume) - liquids = list(reversed(l)) - all_liquids.append(liquids) - - for liquid, vol in liquids: - well.tracker.add_liquid(liquid=liquid, volume=vol) - - dispense96 = DispensePlate( - wells=wells, - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=None, - blow_out_air_volume=blow_out_air_volume, - liquids=all_liquids, - ) + for channel, well in zip(self.head96.values(), wells): + # even if the volume tracker is disabled, a liquid (None, volume) is added to the list + # during the aspiration command + l = channel.get_tip().tracker.remove_liquid(volume=volume) + liquids = list(reversed(l)) + all_liquids.append(liquids) + + for liquid, vol in liquids: + well.tracker.add_liquid(liquid=liquid, volume=vol) + + dispense = DispensePlate( + wells=wells, + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=None, + blow_out_air_volume=blow_out_air_volume, + liquids=all_liquids, + ) try: - await self.backend.dispense96(dispense=dispense96, **backend_kwargs) + await self.backend.dispense96(dispense=dispense, **backend_kwargs) except Exception as error: # pylint: disable=broad-except for channel, well in zip(self.head96.values(), wells): if does_volume_tracking() and not well.tracker.is_disabled: @@ -1453,7 +1558,7 @@ async def dispense96( self._trigger_callback( "dispense96", liquid_handler=self, - dispense=dispense96, + dispense=dispense, error=error, **backend_kwargs, ) @@ -1466,7 +1571,7 @@ async def dispense96( self._trigger_callback( "dispense96", liquid_handler=self, - dispense=dispense96, + dispense=dispense, error=None, **backend_kwargs, ) @@ -1535,6 +1640,9 @@ async def move_resource( put_direction: The direction from which to put down the resource. """ + # TODO: move conditional statements from move_plate into move_resource to enable + # movement to other types besides Coordinate + extras = self._check_args(self.backend.move_resource, backend_kwargs, default={"move"}) for extra in extras: del backend_kwargs[extra] @@ -1552,6 +1660,11 @@ async def move_resource( result = await self.backend.move_resource(move=move_operation, **backend_kwargs) + # rotate the resource if the move operation has a rotation. + # this code should be expanded to also update the resource's location + if move_operation.rotation != 0: + move_operation.resource.rotate(z=move_operation.rotation) + self._trigger_callback( "move_resource", liquid_handler=self, @@ -1571,7 +1684,7 @@ async def move_lid( destination_offset: Coordinate = Coordinate.zero(), get_direction: GripDirection = GripDirection.FRONT, put_direction: GripDirection = GripDirection.FRONT, - pickup_distance_from_top: float = 5.7, + pickup_distance_from_top: float = 5.7-3.33, **backend_kwargs ): """ Move a lid to a new location. @@ -1603,14 +1716,10 @@ async def move_lid( to_location = Coordinate( x=to_location.x, y=to_location.y, - z=to_location.z + to.get_size_z() - lid.get_size_z()) + z=to_location.z + to.get_absolute_size_z() - lid.nesting_z_height) elif isinstance(to, ResourceStack): assert to.direction == "z", "Only ResourceStacks with direction 'z' are currently supported" - to_location = to.get_absolute_location() - to_location = Coordinate( - x=to_location.x, - y=to_location.y, - z=to_location.z + to.get_size_z()) + to_location = to.get_absolute_location(z="top") elif isinstance(to, Coordinate): to_location = to else: @@ -1646,7 +1755,7 @@ async def move_plate( destination_offset: Coordinate = Coordinate.zero(), put_direction: GripDirection = GripDirection.FRONT, get_direction: GripDirection = GripDirection.FRONT, - pickup_distance_from_top: float = 13.2, + pickup_distance_from_top: float = 13.2-3.33, **backend_kwargs ): """ Move a plate to a new location. @@ -1683,15 +1792,30 @@ async def move_plate( if isinstance(to, ResourceStack): assert to.direction == "z", "Only ResourceStacks with direction 'z' are currently supported" - to_location = to.get_absolute_location() - to_location = Coordinate( - x=to_location.x, - y=to_location.y, - z=to_location.z + to.get_size_z()) + to_location = to.get_absolute_location(z="top") elif isinstance(to, Coordinate): to_location = to - elif isinstance(to, MFXModule): + elif isinstance(to, (MFXModule, Tilter)): to_location = to.get_absolute_location() + to.child_resource_location + elif isinstance(to, PlateCarrierSite): + to_location = to.get_absolute_location() + # Sanity check for equal well clearances / dz + well_dz_set = {round(well.location.z, 2) for well in plate.get_all_children() + if well.category == "well" and well.location is not None} + assert len(well_dz_set) == 1, "All wells must have the same dz" + well_dz = well_dz_set.pop() + # Plate "sinking" logic based on well dz to pedestal relationship + # 1. no pedestal + # 2. pedestal taller than plate.well.dz + # 3. pedestal shorter than plate.well.dz + pedestal_size_z = abs(to.pedestal_size_z) + z_sinking_depth = min(pedestal_size_z, well_dz) + correction_anchor = Coordinate(0, 0, -z_sinking_depth) + to_location += correction_anchor + elif isinstance(to, PlateAdapter): + # Calculate location adjustment of Plate based on PlateAdapter geometry + adjusted_plate_anchor = to.compute_plate_location(plate) + to_location = to.get_absolute_location() + adjusted_plate_anchor else: to_location = to.get_absolute_location() @@ -1706,32 +1830,27 @@ async def move_plate( put_direction=put_direction, **backend_kwargs) + # Some of the code below should probably be moved to `move_resource` so that is can be shared + # with the `move_lid` convenience method. plate.unassign() if isinstance(to, Coordinate): to_location -= self.deck.location # passed as an absolute location, but stored as relative self.deck.assign_child_resource(plate, location=to_location) + elif isinstance(to, PlateCarrierSite): # .zero() resources + to.assign_child_resource(plate) elif isinstance(to, CarrierSite): # .zero() resources - to.assign_child_resource(plate, location=Coordinate.zero()) + to.assign_child_resource(plate) elif isinstance(to, (ResourceStack, PlateReader)): # manage its own resources + if isinstance(to, ResourceStack) and to.direction != "z": + raise ValueError("Only ResourceStacks with direction 'z' are currently supported") to.assign_child_resource(plate) - elif isinstance(to, MFXModule): + elif isinstance(to, (MFXModule, Tilter)): to.assign_child_resource(plate, location=to.child_resource_location) + elif isinstance(to, PlateAdapter): + to.assign_child_resource(plate, location=to.compute_plate_location(plate)) else: to.assign_child_resource(plate, location=to_location) - def serialize(self) -> dict: - """ Serialize the liquid handler to a dictionary. - - Returns: - A dictionary representation of the liquid handler. - """ - - return { - # "children": self.deck.serialize(), - **super().serialize(), - "backend": self.backend.serialize() - } - def register_callback(self, method_name: str, callback: OperationCallback): """Registers a callback for a specific method.""" if method_name in self._callbacks: @@ -1757,18 +1876,17 @@ def callbacks(self): return self._callbacks @classmethod - def deserialize(cls, data: dict) -> LiquidHandler: + def deserialize(cls, data: dict, allow_marshal: bool = False) -> LiquidHandler: """ Deserialize a liquid handler from a dictionary. Args: data: A dictionary representation of the liquid handler. """ - backend_data = data.pop("backend") - backend = LiquidHandlerBackend.deserialize(backend_data) deck_data = data["children"][0] - deck = Deck.deserialize(data=deck_data) - return LiquidHandler(deck=deck, backend=backend) + deck = Deck.deserialize(data=deck_data, allow_marshal=allow_marshal) + backend = LiquidHandlerBackend.deserialize(data=data["backend"]) + return cls(deck=deck, backend=backend) @classmethod def load(cls, path: str) -> LiquidHandler: diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/liquid_handling/liquid_handler_tests.py index 311aa5afff..3e091d3dd8 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/liquid_handling/liquid_handler_tests.py @@ -1,16 +1,20 @@ """ Tests for LiquidHandler """ # pylint: disable=missing-class-docstring +import itertools import pytest import tempfile -from typing import Any, Dict, List, Optional, cast +from typing import Any, Dict, List, Optional, Union, cast import unittest import unittest.mock from pylabrobot.liquid_handling.strictness import Strictness, set_strictness -from pylabrobot.resources import no_tip_tracking, set_tip_tracking -from pylabrobot.resources.errors import HasTipError, NoTipError -from pylabrobot.resources.volume_tracker import set_volume_tracking +from pylabrobot.resources import no_tip_tracking, set_tip_tracking, Liquid +from pylabrobot.resources.carrier import PlateCarrierSite +from pylabrobot.resources.errors import HasTipError, NoTipError, CrossContaminationError +from pylabrobot.resources.volume_tracker import set_volume_tracking, set_cross_contamination_tracking +from pylabrobot.resources.well import Well +from pylabrobot.resources.utils import create_ordered_items_2d from . import backends from .liquid_handler import LiquidHandler, OperationCallback @@ -20,16 +24,17 @@ Deck, Lid, Plate, + ResourceStack, TipRack, TIP_CAR_480_A00, PLT_CAR_L5AC_A00, - Cos_96_DW_1mL, - Cos_96_DW_500ul, + Cor_96_wellplate_360ul_Fb, ResourceNotFoundError, ) from pylabrobot.resources.hamilton import STARLetDeck from pylabrobot.resources.ml_star import STF_L, HTF_L from .standard import ( + GripDirection, Pickup, Drop, DropTipRack, @@ -39,11 +44,13 @@ DispensePlate ) -def _make_asp(r: Container, vol: float, tip: Any, offset: Optional[Coordinate]=None) -> Aspiration: +def _make_asp( + r: Container, vol: float, tip: Any, offset: Coordinate=Coordinate.zero()) -> Aspiration: return Aspiration(resource=r, volume=vol, tip=tip, offset=offset, flow_rate=None, liquid_height=None, blow_out_air_volume=None, liquids=[(None, vol)]) -def _make_disp(r: Container, vol: float, tip: Any, offset: Optional[Coordinate]=None) -> Dispense: +def _make_disp( + r: Container, vol: float, tip: Any, offset: Coordinate=Coordinate.zero()) -> Dispense: return Dispense(resource=r, volume=vol, tip=tip, offset=offset, flow_rate=None, liquid_height=None, blow_out_air_volume=None, liquids=[(None, vol)]) @@ -62,8 +69,8 @@ def test_resource_assignment(self): tip_car[3] = HTF_L("tip_rack_04") plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - plt_car[0] = Cos_96_DW_1mL(name="aspiration plate") - plt_car[2] = Cos_96_DW_500ul(name="dispense plate") + plt_car[0] = Cor_96_wellplate_360ul_Fb(name="aspiration plate") + plt_car[2] = Cor_96_wellplate_360ul_Fb(name="dispense plate") self.deck.assign_child_resource(tip_car, rails=1) self.deck.assign_child_resource(plt_car, rails=21) @@ -81,23 +88,6 @@ def test_resource_assignment(self): dbl_plt_car_3 = PLT_CAR_L5AC_A00(name="double placed carrier 3") self.deck.assign_child_resource(dbl_plt_car_3, rails=20) - # Test carrier with same name. - with self.assertRaises(ValueError): - same_name_carrier = PLT_CAR_L5AC_A00(name="plate carrier") - self.deck.assign_child_resource(same_name_carrier, rails=10) - # Should not raise when replacing. - self.deck.assign_child_resource(same_name_carrier, rails=10, replace=True) - # Should not raise when unassinged. - self.lh.unassign_resource("plate carrier") - self.deck.assign_child_resource(same_name_carrier, rails=10, replace=True) - - # Test unassigning unassigned resource - self.lh.unassign_resource("plate carrier") - with self.assertRaises(ResourceNotFoundError): - self.lh.unassign_resource("plate carrier") - with self.assertRaises(ResourceNotFoundError): - self.lh.unassign_resource("this resource is completely new.") - # Test invalid rails. with self.assertRaises(ValueError): self.deck.assign_child_resource(plt_car, rails=-1) @@ -110,7 +100,7 @@ def test_get_resource(self): tip_car = TIP_CAR_480_A00(name="tip_carrier") tip_car[0] = STF_L(name="tip_rack_01") plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - plt_car[0] = Cos_96_DW_1mL(name="aspiration plate") + plt_car[0] = Cor_96_wellplate_360ul_Fb(name="aspiration plate") self.deck.assign_child_resource(tip_car, rails=1) self.deck.assign_child_resource(plt_car, rails=10) @@ -131,8 +121,8 @@ def test_subcoordinates(self): tip_car[0] = STF_L(name="tip_rack_01") tip_car[3] = HTF_L(name="tip_rack_04") plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - plt_car[0] = Cos_96_DW_1mL(name="aspiration plate") - plt_car[2] = Cos_96_DW_500ul(name="dispense plate") + plt_car[0] = Cor_96_wellplate_360ul_Fb(name="aspiration plate") + plt_car[2] = Cor_96_wellplate_360ul_Fb(name="dispense plate") self.deck.assign_child_resource(tip_car, rails=1) self.deck.assign_child_resource(plt_car, rails=10) @@ -163,7 +153,7 @@ def test_subcoordinates(self): cast(Plate, self.lh.deck.get_resource("aspiration plate")).get_item("A1") .get_absolute_location() + cast(Plate, self.lh.deck.get_resource("aspiration plate")).get_item("A1").center(), - Coordinate(320.500, 146.000, 187.150)) + Coordinate(x=320.8, y=145.7, z=186.15) ) def test_illegal_subresource_assignment_before(self): # Test assigning subresource with the same name as another resource in another carrier. This @@ -171,7 +161,7 @@ def test_illegal_subresource_assignment_before(self): tip_car = TIP_CAR_480_A00(name="tip_carrier") tip_car[0] = STF_L(name="sub") plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - plt_car[0] = Cos_96_DW_1mL(name="sub") + plt_car[0] = Cor_96_wellplate_360ul_Fb(name="sub") self.deck.assign_child_resource(tip_car, rails=1) with self.assertRaises(ValueError): self.deck.assign_child_resource(plt_car, rails=10) @@ -182,15 +172,15 @@ def test_illegal_subresource_assignment_after(self): tip_car = TIP_CAR_480_A00(name="tip_carrier") tip_car[0] = STF_L(name="sub") plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - plt_car[0] = Cos_96_DW_1mL(name="ok") + plt_car[0] = Cor_96_wellplate_360ul_Fb(name="ok") self.deck.assign_child_resource(tip_car, rails=1) self.deck.assign_child_resource(plt_car, rails=10) with self.assertRaises(ValueError): - plt_car[1] = Cos_96_DW_500ul(name="sub") + plt_car[1] = Cor_96_wellplate_360ul_Fb(name="sub") async def test_move_plate_to_site(self): plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - plt_car[0] = plate = Cos_96_DW_1mL(name="plate") + plt_car[0] = plate = Cor_96_wellplate_360ul_Fb(name="plate") self.deck.assign_child_resource(plt_car, rails=21) await self.lh.move_plate(plate, plt_car[2]) @@ -198,11 +188,11 @@ async def test_move_plate_to_site(self): self.assertIsNone(plt_car[0].resource) self.assertEqual(plt_car[2].resource, self.lh.deck.get_resource("plate")) self.assertEqual(plate.get_item("A1").get_absolute_location() + plate.get_item("A1").center(), - Coordinate(568.000, 338.000, 187.150)) + Coordinate(x=568.3, y=337.7, z=186.15)) async def test_move_plate_free(self): plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - plt_car[0] = plate = Cos_96_DW_1mL(name="plate") + plt_car[0] = plate = Cor_96_wellplate_360ul_Fb(name="plate") self.deck.assign_child_resource(plt_car, rails=1) await self.lh.move_plate(plate, Coordinate(1000, 1000, 1000)) @@ -212,24 +202,122 @@ async def test_move_plate_free(self): Coordinate(1000, 1000, 1000)) async def test_move_lid(self): - plate = Plate("plate", size_x=100, size_y=100, size_z=15, lid_height=10, items=[]) + plate = Plate("plate", size_x=100, size_y=100, size_z=15, ordered_items={}) plate.location = Coordinate(0, 0, 100) - lid = Lid(name="lid", size_x=plate.get_size_x(), size_y=plate.get_size_y(), - size_z=plate.lid_height) + lid_height = 10 + lid = Lid(name="lid", size_x=plate.get_absolute_size_x(), size_y=plate.get_absolute_size_y(), + size_z=lid_height, nesting_z_height=lid_height) lid.location = Coordinate(100, 100, 200) assert plate.get_absolute_location().x != lid.get_absolute_location().x assert plate.get_absolute_location().y != lid.get_absolute_location().y - assert plate.get_absolute_location().z + plate.get_size_z() - plate.lid_height \ + assert plate.get_absolute_location().z + plate.get_absolute_size_z() - lid_height \ != lid.get_absolute_location().z await self.lh.move_lid(lid, plate) assert plate.get_absolute_location().x == lid.get_absolute_location().x assert plate.get_absolute_location().y == lid.get_absolute_location().y - assert plate.get_absolute_location().z + plate.get_size_z() - plate.lid_height \ + assert plate.get_absolute_location().z + plate.get_absolute_size_z() - lid_height \ == lid.get_absolute_location().z + async def test_move_plate_onto_resource_stack_with_lid(self): + plate = Plate("plate", size_x=100, size_y=100, size_z=15, ordered_items={}) + lid = Lid(name="lid", size_x=plate.get_absolute_size_x(), size_y=plate.get_absolute_size_y(), + size_z=10, nesting_z_height=4) + + stack = ResourceStack("stack", direction="z") + self.deck.assign_child_resource(stack, location=Coordinate(100, 100, 0)) + + await self.lh.move_plate(plate, stack) + await self.lh.move_lid(lid, plate) + + assert plate.location is not None + self.assertEqual(plate.location.z, 0) + assert lid.location is not None + self.assertEqual(lid.location.z, 11) + self.assertEqual(plate.lid, lid) + self.assertEqual(stack.get_absolute_size_z(), 21) + + async def test_move_plate_onto_resource_stack_with_plate(self): + plate1 = Plate("plate1", size_x=100, size_y=100, size_z=15, ordered_items={}) + plate2 = Plate("plate2", size_x=100, size_y=100, size_z=15, ordered_items={}) + + stack = ResourceStack("stack", direction="z") + + self.deck.assign_child_resource(stack, location=Coordinate(100, 100, 0)) + await self.lh.move_plate(plate1, stack) + await self.lh.move_plate(plate2, stack) + + assert plate1.location is not None and plate2.location is not None + self.assertEqual(plate1.location.z, 0) + self.assertEqual(plate2.location.z, 15) + self.assertEqual(stack.get_absolute_size_z(), 30) + + async def test_move_plate_rotation(self): + rotations = [0, 90, 270, 360] + grip_directions = [ + (GripDirection.LEFT, GripDirection.RIGHT), + (GripDirection.FRONT, GripDirection.BACK), + ] + sites: List[Union[ResourceStack, PlateCarrierSite]] = [ + ResourceStack(name="stack", direction="z"), + PlateCarrierSite(name="site", size_x=100, size_y=100, size_z=15, pedestal_size_z=1) + ] + + test_cases = itertools.product(sites, rotations, grip_directions) + + for site, rotation, (get_direction, put_direction) in test_cases: + with self.subTest(stack_type=site.__class__.__name__, rotation=rotation, + get_direction=get_direction, put_direction=put_direction): + self.deck.assign_child_resource(site, location=Coordinate(100, 100, 0)) + + plate = Plate("plate", size_x=200, size_y=100, size_z=15, + ordered_items=create_ordered_items_2d( + Well, num_items_x=1, num_items_y=1, dx=0, dy=0, dz=0, + item_dx=10, item_dy=10, size_x=10, size_y=10, size_z=10)) + plate.rotate(z=rotation) + site.assign_child_resource(plate) + original_center = plate.get_absolute_location(x="c", y="c", z="c") + await self.lh.move_plate(plate, site, get_direction=get_direction, + put_direction=put_direction) + new_center = plate.get_absolute_location(x="c", y="c", z="c") + + self.assertEqual(new_center, original_center, + f"Center mismatch for {site.__class__.__name__}, rotation {rotation}, " + f"get_direction {get_direction}, " + f"put_direction {put_direction}") + plate.unassign() + self.deck.unassign_child_resource(site) + + async def test_move_lid_rotation(self): + rotations = [0, 90, 270, 360] + grip_directions = [ + (GripDirection.LEFT, GripDirection.RIGHT), + (GripDirection.FRONT, GripDirection.BACK), + ] + + test_cases = itertools.product(rotations, grip_directions) + + plate = Plate("plate", size_x=200, size_y=100, size_z=15, ordered_items={}) + lid = Lid(name="lid", size_x=plate.get_absolute_size_x(), size_y=plate.get_absolute_size_y(), + size_z=10, nesting_z_height=4) + self.deck.assign_child_resource(plate, location=Coordinate(100, 100, 0)) + for rot, (get_direction, put_direction) in test_cases: + with self.subTest(rotation=rot, get_direction=get_direction, put_direction=put_direction): + plate.rotate(z=rot) + plate.assign_child_resource(lid) + original_center = lid.get_absolute_location(x="c", y="c", z="c") + await self.lh.move_lid(lid, plate, get_direction=get_direction, put_direction=put_direction) + new_center = lid.get_absolute_location(x="c", y="c", z="c") + self.assertEqual(new_center, original_center, + f"Center mismatch for rotation {rot}, get_direction {get_direction}, " + f"put_direction {put_direction}") + lid.unassign() + # reset rotations + plate.rotation.z = 0 + lid.rotation.z = 0 + def test_serialize(self): serialized = self.lh.serialize() deserialized = LiquidHandler.deserialize(serialized) @@ -248,7 +336,7 @@ async def asyncSetUp(self): self.lh = LiquidHandler(backend=self.backend, deck=self.deck) self.tip_rack = STF_L(name="tip_rack") - self.plate = Cos_96_DW_1mL(name="plate") + self.plate = Cor_96_wellplate_360ul_Fb(name="plate") self.deck.assign_child_resource(self.tip_rack, location=Coordinate(0, 0, 0)) self.deck.assign_child_resource(self.plate, location=Coordinate(100, 100, 0)) await self.lh.setup() @@ -262,8 +350,8 @@ def get_first_command(self, command) -> Optional[Dict[str, Any]]: async def test_offsets_tips(self): tip_spot = self.tip_rack.get_item("A1") tip = tip_spot.get_tip() - await self.lh.pick_up_tips([tip_spot], offsets=Coordinate(x=1, y=1, z=1)) - await self.lh.drop_tips([tip_spot], offsets=Coordinate(x=1, y=1, z=1)) + await self.lh.pick_up_tips([tip_spot], offsets=[Coordinate(x=1, y=1, z=1)]) + await self.lh.drop_tips([tip_spot], offsets=[Coordinate(x=1, y=1, z=1)]) self.assertEqual(self.get_first_command("pick_up_tips"), { "command": "pick_up_tips", @@ -292,21 +380,21 @@ async def test_with_use_channels(self): "kwargs": { "use_channels": [2], "ops": [ - Pickup(tip_spot, tip=tip, offset=None)]}}) + Pickup(tip_spot, tip=tip, offset=Coordinate.zero())]}}) self.assertEqual(self.get_first_command("drop_tips"), { "command": "drop_tips", "args": (), "kwargs": { "use_channels": [2], "ops": [ - Drop(tip_spot, tip=tip, offset=None)]}}) + Drop(tip_spot, tip=tip, offset=Coordinate.zero())]}}) async def test_offsets_asp_disp(self): well = self.plate.get_item("A1") well.tracker.set_liquids([(None, 10)]) t = self.tip_rack.get_item("A1").get_tip() self.lh.update_head_state({0: t}) - await self.lh.aspirate([well], vols=10, offsets=Coordinate(x=1, y=1, z=1)) - await self.lh.dispense([well], vols=10, offsets=Coordinate(x=1, y=1, z=1)) + await self.lh.aspirate([well], vols=[10], offsets=[Coordinate(x=1, y=1, z=1)]) + await self.lh.dispense([well], vols=[10], offsets=[Coordinate(x=1, y=1, z=1)]) self.assertEqual(self.get_first_command("aspirate"), { "command": "aspirate", @@ -332,7 +420,7 @@ async def test_return_tips(self): "args": (), "kwargs": { "use_channels": [0], - "ops": [Drop(tip_spot, tip=tip, offset=None)]}}) + "ops": [Drop(tip_spot, tip=tip, offset=Coordinate.zero())]}}) with self.assertRaises(RuntimeError): await self.lh.return_tips() @@ -483,13 +571,17 @@ async def test_stamp(self): self.backend.clear() async def test_tip_tracking_double_pickup(self): - await self.lh.pick_up_tips(self.tip_rack["A1"]) - set_tip_tracking(enabled=True) + await self.lh.pick_up_tips(self.tip_rack["A1"]) with self.assertRaises(HasTipError): await self.lh.pick_up_tips(self.tip_rack["A2"]) + await self.lh.drop_tips(self.tip_rack["A1"]) + # pick_up_tips should work even after causing a HasTipError + await self.lh.pick_up_tips(self.tip_rack["A2"]) + await self.lh.drop_tips(self.tip_rack["A2"]) set_tip_tracking(enabled=False) + self.lh.clear_head_state() with no_tip_tracking(): await self.lh.pick_up_tips(self.tip_rack["A2"]) @@ -528,7 +620,9 @@ async def test_discard_tips(self): tips = self.tip_rack.get_tips("A1:D1") await self.lh.pick_up_tips(self.tip_rack["A1", "B1", "C1", "D1"], use_channels=[0, 1, 3, 4]) await self.lh.discard_tips() - offsets = list(reversed(self.deck.get_trash_area().centers(yn=4))) + trash = self.deck.get_trash_area() + offsets = list(reversed(trash.centers(yn=4))) + offsets = [o - trash.center() for o in offsets] # offset is wrt trash center self.assertEqual(self.get_first_command("drop_tips"), { "command": "drop_tips", @@ -547,24 +641,21 @@ async def test_discard_tips(self): await self.lh.discard_tips() async def test_aspirate_with_lid(self): - lid = Lid("lid", - size_x=self.plate.get_size_x(), - size_y=self.plate.get_size_y(), - size_z=self.plate.lid_height) - self.plate.assign_child_resource(lid, location=Coordinate(0, 0, - self.plate.get_size_z() - self.plate.lid_height)) + lid = Lid("lid", size_x=self.plate.get_size_x(), size_y=self.plate.get_size_y(), + size_z=10, nesting_z_height=self.plate.get_size_z()) + self.plate.assign_child_resource(lid) well = self.plate.get_item("A1") well.tracker.set_liquids([(None, 10)]) t = self.tip_rack.get_item("A1").get_tip() self.lh.update_head_state({0: t}) with self.assertRaises(ValueError): - await self.lh.aspirate([well], vols=10) + await self.lh.aspirate([well], vols=[10]) @pytest.mark.filterwarnings("ignore:Extra arguments to backend.pick_up_tips") async def test_strictness(self): class TestBackend(backends.SaverBackend): """ Override pick_up_tips for testing. """ - async def pick_up_tips(self, ops, use_channels, non_default, default=True): + async def pick_up_tips(self, ops, use_channels, non_default, default=True): # type: ignore # pylint: disable=unused-argument assert non_default == default @@ -585,6 +676,7 @@ async def pick_up_tips(self, ops, use_channels, non_default, default=True): with self.assertWarns(UserWarning): # extra kwargs should warn await self.lh.pick_up_tips(self.tip_rack["A1"], use_channels=[4], non_default=True, does_not_exist=True) + self.lh.clear_head_state() # We override default to False, so this should raise an assertion error. To test whether # overriding default to True works. with self.assertRaises(AssertionError): @@ -594,6 +686,7 @@ async def pick_up_tips(self, ops, use_channels, non_default, default=True): await self.lh.pick_up_tips(self.tip_rack["A1"], use_channels=[5]) set_strictness(Strictness.STRICT) + self.lh.clear_head_state() await self.lh.pick_up_tips(self.tip_rack["A1"], non_default=True, use_channels=[6]) with self.assertRaises(TypeError): # cannot have extra kwargs await self.lh.pick_up_tips(self.tip_rack["A1"], use_channels=[7], @@ -629,6 +722,103 @@ async def test_save_state(self): set_volume_tracking(enabled=False) +class TestLiquidHandlerVolumeTracking(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = backends.SaverBackend(num_channels=8) + self.deck = STARLetDeck() + self.lh = LiquidHandler(backend=self.backend, deck=self.deck) + self.tip_rack = STF_L(name="tip_rack") + self.plate = Cor_96_wellplate_360ul_Fb(name="plate") + self.deck.assign_child_resource(self.tip_rack, location=Coordinate(0, 0, 0)) + self.deck.assign_child_resource(self.plate, location=Coordinate(100, 100, 0)) + await self.lh.setup() + set_volume_tracking(enabled=True) + + async def asyncTearDown(self): + set_volume_tracking(enabled=False) + + async def test_dispense_with_volume_tracking(self): + well = self.plate.get_item("A1") + await self.lh.pick_up_tips(self.tip_rack["A1"]) + well.tracker.set_liquids([(None, 10)]) + await self.lh.aspirate([well], vols=[10]) + await self.lh.dispense([well], vols=[10]) + self.assertEqual(well.tracker.liquids, [(None, 10)]) + + async def test_mix_volume_tracking(self): + for i in range(8): + self.plate.get_item(i).set_liquids([(Liquid.SERUM, 55)]) + + await self.lh.pick_up_tips(self.tip_rack[0:8]) + initial_liquids = [self.plate.get_item(i).tracker.liquids for i in range(8)] + for _ in range(10): + await self.lh.aspirate(self.plate[0:8], vols=[45]*8) + await self.lh.dispense(self.plate[0:8], vols=[45]*8) + liquids_now = [self.plate.get_item(i).tracker.liquids for i in range(8)] + self.assertEqual(liquids_now, initial_liquids) + + async def test_channel_1_liquid_tracking(self): + self.plate.get_item("A1").tracker.set_liquids([(Liquid.WATER, 10)]) + with self.lh.use_channels([1]): + await self.lh.pick_up_tips(self.tip_rack["A1"]) + await self.lh.aspirate([self.plate.get_item("A1")], vols=[10]) + await self.lh.dispense([self.plate.get_item("A2")], vols=[10]) + +class TestLiquidHandlerCrossContaminationTracking(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = backends.SaverBackend(num_channels=8) + self.deck = STARLetDeck() + self.lh = LiquidHandler(backend=self.backend, deck=self.deck) + self.tip_rack = STF_L(name="tip_rack") + self.plate = Cor_96_wellplate_360ul_Fb(name="plate") + self.deck.assign_child_resource(self.tip_rack, location=Coordinate(0, 0, 0)) + self.deck.assign_child_resource(self.plate, location=Coordinate(100, 100, 0)) + await self.lh.setup() + set_volume_tracking(enabled=True) + set_cross_contamination_tracking(enabled=True) + + async def asyncTearDown(self): + set_volume_tracking(enabled=False) + set_cross_contamination_tracking(enabled=False) + + async def test_aspirate_with_contaminated_tip(self): + blood_well = self.plate.get_item("A1") + etoh_well = self.plate.get_item("A2") + dest_well = self.plate.get_item("A3") + await self.lh.pick_up_tips(self.tip_rack["A1"]) + blood_well.tracker.set_liquids([(Liquid.BLOOD, 10)]) + etoh_well.tracker.set_liquids([(Liquid.ETHANOL, 10)]) + await self.lh.aspirate([blood_well], vols=[10]) + await self.lh.dispense([dest_well], vols=[10]) + with self.assertRaises(CrossContaminationError): + await self.lh.aspirate([etoh_well], vols=[10]) + + async def test_aspirate_from_same_well_twice(self): + src_well = self.plate.get_item("A1") + dst_well = self.plate.get_item("A2") + await self.lh.pick_up_tips(self.tip_rack["A1"]) + src_well.tracker.set_liquids([(Liquid.BLOOD, 20)]) + await self.lh.aspirate([src_well], vols=[10]) + await self.lh.dispense([dst_well], vols=[10]) + self.assertEqual(dst_well.tracker.liquids, [(Liquid.BLOOD, 10)]) + await self.lh.aspirate([src_well], vols=[10]) + await self.lh.dispense([dst_well], vols=[10]) + self.assertEqual(dst_well.tracker.liquids, [(Liquid.BLOOD, 20)]) + + async def test_aspirate_from_well_with_partial_overlap(self): + pure_blood_well = self.plate.get_item("A1") + mix_well = self.plate.get_item("A2") + await self.lh.pick_up_tips(self.tip_rack["A1"]) + pure_blood_well.tracker.set_liquids([(Liquid.BLOOD, 20)]) + mix_well.tracker.set_liquids([(Liquid.ETHANOL, 20)]) + await self.lh.aspirate([pure_blood_well], vols=[10]) + await self.lh.dispense([mix_well], vols=[10]) + self.assertEqual(mix_well.tracker.liquids, [(Liquid.ETHANOL, 20), + (Liquid.BLOOD, 10)]) # order matters + with self.assertRaises(CrossContaminationError): + await self.lh.aspirate([pure_blood_well], vols=[10]) + + class LiquidHandlerForTesting(LiquidHandler): ALLOWED_CALLBACKS = { "test_operation", diff --git a/pylabrobot/liquid_handling/standard.py b/pylabrobot/liquid_handling/standard.py index 5d43eb2faa..b4f2acb7f9 100644 --- a/pylabrobot/liquid_handling/standard.py +++ b/pylabrobot/liquid_handling/standard.py @@ -14,44 +14,36 @@ from pylabrobot.resources.tip_rack import TipSpot -@dataclass +@dataclass(frozen=True) class Pickup: - """ A pickup operation. """ resource: TipSpot - offset: Optional[Coordinate] + offset: Coordinate tip: Tip # TODO: perhaps we can remove this, because the tip spot has the tip? -@dataclass +@dataclass(frozen=True) class Drop: - """ A drop operation. """ resource: Resource - offset: Optional[Coordinate] + offset: Coordinate tip: Tip -@dataclass +@dataclass(frozen=True) class PickupTipRack: - """ A pickup operation for an entire tip rack. """ - resource: TipRack - offset: Optional[Coordinate] + offset: Coordinate -@dataclass +@dataclass(frozen=True) class DropTipRack: - """ A drop operation for an entire tip rack. """ - resource: Union[TipRack, Trash] - offset: Optional[Coordinate] + offset: Coordinate -@dataclass +@dataclass(frozen=True) class Aspiration: - """ Aspiration contains information about an aspiration. """ - resource: Container - offset: Optional[Coordinate] + offset: Coordinate tip: Tip volume: float flow_rate: Optional[float] @@ -60,12 +52,10 @@ class Aspiration: liquids: List[Tuple[Optional[Liquid], float]] -@dataclass +@dataclass(frozen=True) class Dispense: - """ Dispense contains information about an dispense. """ - resource: Container - offset: Optional[Coordinate] + offset: Coordinate tip: Tip volume: float flow_rate: Optional[float] @@ -74,12 +64,10 @@ class Dispense: liquids: List[Tuple[Optional[Liquid], float]] -@dataclass +@dataclass(frozen=True) class AspirationPlate: - """ Contains information about an aspiration from a plate (in a single movement). """ - wells: List[Well] - offset: Optional[Coordinate] + offset: Coordinate tips: List[Tip] volume: float flow_rate: Optional[float] @@ -88,12 +76,33 @@ class AspirationPlate: liquids: List[List[Tuple[Optional[Liquid], float]]] -@dataclass +@dataclass(frozen=True) class DispensePlate: - """ Contains information about an aspiration from a plate (in a single movement). """ - wells: List[Well] - offset: Optional[Coordinate] + offset: Coordinate + tips: List[Tip] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + liquids: List[List[Tuple[Optional[Liquid], float]]] + +@dataclass(frozen=True) +class AspirationContainer: + container: Container + offset: Coordinate + tips: List[Tip] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + liquids: List[List[Tuple[Optional[Liquid], float]]] + + +@dataclass(frozen=True) +class DispenseContainer: + container: Container + offset: Coordinate tips: List[Tip] volume: float flow_rate: Optional[float] @@ -103,17 +112,15 @@ class DispensePlate: class GripDirection(enum.Enum): - """ A direction from which to grab the resource. """ FRONT = enum.auto() BACK = enum.auto() LEFT = enum.auto() RIGHT = enum.auto() -@dataclass +@dataclass(frozen=True) class Move: - """ A move operation. - + """ Attributes: resource: The resource to move. destination: The destination of the move. @@ -143,19 +150,21 @@ def rotation(self) -> int: (GripDirection.BACK, GripDirection.LEFT), (GripDirection.LEFT, GripDirection.FRONT), ): - return 270 + return 90 if (self.get_direction, self.put_direction) in ( (GripDirection.FRONT, GripDirection.BACK), + (GripDirection.BACK, GripDirection.FRONT), (GripDirection.LEFT, GripDirection.RIGHT), + (GripDirection.RIGHT, GripDirection.LEFT), ): return 180 - if (self.put_direction, self.get_direction) in ( - (GripDirection.FRONT, GripDirection.RIGHT), - (GripDirection.RIGHT, GripDirection.BACK), - (GripDirection.BACK, GripDirection.LEFT), - (GripDirection.LEFT, GripDirection.FRONT), + if (self.get_direction, self.put_direction) in ( + (GripDirection.RIGHT, GripDirection.FRONT), + (GripDirection.BACK, GripDirection.RIGHT), + (GripDirection.LEFT, GripDirection.BACK), + (GripDirection.FRONT, GripDirection.LEFT), ): - return 90 + return 270 raise ValueError(f"Invalid grip directions: {self.get_direction}, {self.put_direction}") PipettingOp = Union[Pickup, Drop, Aspiration, Dispense] diff --git a/pylabrobot/machine.py b/pylabrobot/machine.py deleted file mode 100644 index fba754aae9..0000000000 --- a/pylabrobot/machine.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from abc import ABC, ABCMeta, abstractmethod -import functools -from typing import Callable, Optional - -from pylabrobot.resources import Resource - - -def need_setup_finished(func: Callable): - """ Decorator for methods that require the liquid handler to be set up. - - Checked by verifying `self.setup_finished` is `True`. - - Raises: - RuntimeError: If the liquid handler is not set up. - """ - - @functools.wraps(func) - async def wrapper(self: Machine, *args, **kwargs): - if not self.setup_finished: - raise RuntimeError("The setup has not finished. See `setup`.") - return await func(self, *args, **kwargs) - return wrapper - - -class MachineBackend(ABC): - """ Abstract class for machine backends. """ - - @abstractmethod - async def setup(self): - pass - - @abstractmethod - async def stop(self): - pass - - -class Machine(Resource, metaclass=ABCMeta): - """ Abstract class for machine frontends. All Machines are Resources. """ - - @abstractmethod - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - backend: MachineBackend, - category: Optional[str] = None, - model: Optional[str] = None, - ): - super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, - category=category, model=model) - self.backend = backend - self._setup_finished = False - - @property - def setup_finished(self) -> bool: - return self._setup_finished - - async def setup(self): - await self.backend.setup() - self._setup_finished = True - - @need_setup_finished - async def stop(self): - await self.backend.stop() - self._setup_finished = False - - async def __aenter__(self): - await self.setup() - return self - - async def __aexit__(self, exc_type, exc_value, traceback): - await self.stop() diff --git a/pylabrobot/only_fans/chatterbox.py b/pylabrobot/only_fans/chatterbox.py new file mode 100644 index 0000000000..af9515fff4 --- /dev/null +++ b/pylabrobot/only_fans/chatterbox.py @@ -0,0 +1,17 @@ +from pylabrobot.only_fans import FanBackend + + +class FanChatterboxBackend(FanBackend): + """ Chatter box backend for device-free testing. Prints out all operations. """ + + async def setup(self) -> None: + print("Setting up the fan.") + + async def turn_on(self, intensity: int) -> None: + print(f"Turning on the fan at intensity {intensity}.") + + async def turn_off(self) -> None: + print("Turning off the fan.") + + async def stop(self) -> None: + print("Stopping the fan.") diff --git a/pylabrobot/plate_reading/__init__.py b/pylabrobot/plate_reading/__init__.py index 5b8a243ed8..32e18727b0 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/plate_reading/__init__.py @@ -1,2 +1,3 @@ from .plate_reader import PlateReader +from .biotek_backend import Cytation5Backend from .clario_star import CLARIOStar diff --git a/pylabrobot/plate_reading/backend.py b/pylabrobot/plate_reading/backend.py index 555684e48c..8a09b4fa0c 100644 --- a/pylabrobot/plate_reading/backend.py +++ b/pylabrobot/plate_reading/backend.py @@ -1,15 +1,9 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -import sys -from typing import List, Optional, Type +from typing import List -from pylabrobot.machine import MachineBackend - -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal +from pylabrobot.machines.backends import MachineBackend class PlateReaderBackend(MachineBackend, metaclass=ABCMeta): @@ -38,45 +32,16 @@ async def read_luminescence(self, focal_height: float) -> List[List[float]]: outer list is the columns of the plate and the inner list is the rows of the plate. """ @abstractmethod - async def read_absorbance( - self, - wavelength: int, - report: Literal["OD", "transmittance"] - ) -> List[List[float]]: + async def read_absorbance(self, wavelength: int) -> List[List[float]]: """ Read the absorbance from the plate reader. This should return a list of lists, where the outer list is the columns of the plate and the inner list is the rows of the plate. """ - # Copied from liquid_handling/backend.py. Maybe we should create a shared base class? - - def serialize(self): - """ Serialize the backend so that an equivalent backend can be created by passing the dict - as kwargs to the initializer. The dict must contain a key "type" that specifies the type of - backend to create. This key will be removed from the dict before passing it to the initializer. - """ - - return { - "type": self.__class__.__name__, - } - - @classmethod - def deserialize(cls, data: dict) -> PlateReaderBackend: - """ Deserialize the backend. Unless a custom serialization method is implemented, this method - should not be overridden. """ - - # Recursively find a subclass with the correct name - def find_subclass(cls: Type[PlateReaderBackend], name: str) -> \ - Optional[Type[PlateReaderBackend]]: - if cls.__name__ == name: - return cls - for subclass in cls.__subclasses__(): - subclass_ = find_subclass(subclass, name) - if subclass_ is not None: - return subclass_ - return None - - subclass = find_subclass(cls, data["type"]) - if subclass is None: - raise ValueError(f"Could not find subclass with name {data['type']}") - - del data["type"] - return subclass(**data) + @abstractmethod + async def read_fluorescence( + self, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float + ) -> List[List[float]]: + """ Read the fluorescence from the plate reader. This should return a list of lists, where the + outer list is the columns of the plate and the inner list is the rows of the plate. """ diff --git a/pylabrobot/plate_reading/biotek_backend.py b/pylabrobot/plate_reading/biotek_backend.py new file mode 100644 index 0000000000..f9d278b5f2 --- /dev/null +++ b/pylabrobot/plate_reading/biotek_backend.py @@ -0,0 +1,289 @@ +import asyncio +import enum +import logging +import time +from typing import List, Optional, Union + +try: + from pylibftdi import Device + USE_FTDI = True +except ImportError: + USE_FTDI = False + +from pylabrobot.plate_reading.backend import PlateReaderBackend + + +logger = logging.getLogger("pylabrobot.plate_reading.biotek") + + +class Cytation5Backend(PlateReaderBackend): + """ Backend for biotek cytation 5 image reader """ + def __init__(self, timeout: float = 20) -> None: + super().__init__() + self.timeout = timeout + if not USE_FTDI: + raise RuntimeError("pylibftdi is not installed. Run `pip install pylabrobot[plate_reading]`.") + + self.dev = Device(lazy_open=True) + + async def setup(self) -> None: + logger.info("[cytation5] setting up") + self.dev.open() + # self.dev.baudrate = 9600 # worked in the past + self.dev.baudrate = 38400 + self.dev.ftdi_fn.ftdi_set_line_property(8, 2, 0) # 8 bits, 2 stop bits, no parity + SIO_RTS_CTS_HS = 0x1 << 8 + self.dev.ftdi_fn.ftdi_setflowctrl(SIO_RTS_CTS_HS) + self.dev.ftdi_fn.ftdi_setrts(1) + + self._shaking = False + self._shaking_task: Optional[asyncio.Task] = None + + async def stop(self) -> None: + logger.info("[cytation5] stopping") + await self.stop_shaking() + self.dev.close() + + async def _purge_buffers(self) -> None: + """ Purge the RX and TX buffers, as implemented in Gen5.exe """ + for _ in range(6): + self.dev.ftdi_fn.ftdi_usb_purge_rx_buffer() + self.dev.ftdi_fn.ftdi_usb_purge_tx_buffer() + + async def _read_until(self, char: bytes, timeout: Optional[float] = None) -> bytes: + """ If timeout is None, use self.timeout """ + if timeout is None: + timeout = self.timeout + x = None + res = b"" + t0 = time.time() + while x != char: + x = self.dev.read(1) + res += x + + if time.time() - t0 > timeout: + logger.debug("[cytation5] received incomplete %s", res) + raise TimeoutError("Timeout while waiting for response") + + if x == b"": + await asyncio.sleep(0.01) + + logger.debug("[cytation5] received %s", res) + return res + + async def send_command( + self, + command: Union[bytes, str], + purge: bool = True, + wait_for_char: Optional[bytes] = b"\x03") -> Optional[bytes]: + if purge: + # real software does this, but I don't think it's necessary + await self._purge_buffers() + + if not isinstance(command, bytes): + command = command.encode() + self.dev.write(command) + logger.debug("[cytation5] sent %s", command) + + if wait_for_char is None: + return None + + return await self._read_until(wait_for_char) + + async def get_serial_number(self) -> str: + resp = await self.send_command("C") + assert resp is not None + return resp[1:].split(b" ")[0].decode() + + async def get_firmware_version(self) -> str: + resp = await self.send_command("e") + assert resp is not None + return " ".join(resp[1:-1].decode().split(" ")[0:4]) + + async def open(self): + return await self.send_command("J") + + async def close(self): + return await self.send_command("A") + + async def get_current_temperature(self) -> float: + """ Get current temperature in degrees Celsius. """ + resp = await self.send_command("h") + assert resp is not None + return int(resp[1:-1]) / 100000 + + def _parse_body(self, body: bytes) -> List[List[float]]: + start_index = body.index(b"01,01") + end_index = body.rindex(b"\r\n") + num_rows = 8 + rows = body[start_index:end_index].split(b"\r\n,")[:num_rows] + + parsed_data: List[List[float]] = [] + for row_idx, row in enumerate(rows): + parsed_data.append([]) + values = row.split(b",") + grouped_values = [values[i:i+3] for i in range(0, len(values), 3)] + + for group in grouped_values: + assert len(group) == 3 + value = float(group[2].decode()) + parsed_data[row_idx].append(value) + return parsed_data + + async def read_absorbance(self, wavelength: int) -> List[List[float]]: + if not 230 <= wavelength <= 999: + raise ValueError("Wavelength must be between 230 and 999") + + resp = await self.send_command("y", wait_for_char=b"\x06") + assert resp == b"\x06" + await self.send_command(b"08120112207434014351135308559127881772\x03", purge=False) + + resp = await self.send_command("D", wait_for_char=b"\x06") + assert resp == b"\x06" + wavelength_str = str(wavelength).zfill(4) + cmd = f"00470101010812000120010000110010000010600008{wavelength_str}1".encode() + checksum = str(sum(cmd) % 100).encode() + cmd = cmd + checksum + b"\x03" + await self.send_command(cmd, purge=False) + + resp1 = await self.send_command("O", wait_for_char=b"\x06") + assert resp1 == b"\x06" + resp2 = await self._read_until(b"\x03") + assert resp2 == b"0000\x03" + + # read data + body = await self._read_until(b"\x03") + assert resp is not None + return self._parse_body(body) + + async def read_luminescence(self, focal_height: float) -> List[List[float]]: + if not 4.5 <= focal_height <= 13.88: + raise ValueError("Focal height must be between 4.5 and 13.88") + + resp = await self.send_command("t", wait_for_char=b"\x06") + assert resp == b"\x06" + + cmd = f"3{14220 + int(1000*focal_height)}\x03".encode() + await self.send_command(cmd, purge=False) + + resp = await self.send_command("y", wait_for_char=b"\x06") + assert resp == b"\x06" + await self.send_command(b"08120112207434014351135308559127881772\x03", purge=False) + + resp = await self.send_command("D", wait_for_char=b"\x06") + assert resp == b"\x06" + cmd = (b"008401010108120001200100001100100000123000500200200" + b"-001000-00300000000000000000001351092") + await self.send_command(cmd, purge=False) + + resp1 = await self.send_command("O", wait_for_char=b"\x06") + assert resp1 == b"\x06" + resp2 = await self._read_until(b"\x03") + assert resp2 == b"0000\x03" + + body = await self._read_until(b"\x03", timeout=60*3) + assert body is not None + return self._parse_body(body) + + async def read_fluorescence( + self, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[List[float]]: + if not 4.5 <= focal_height <= 13.88: + raise ValueError("Focal height must be between 4.5 and 13.88") + if not 250 <= excitation_wavelength <= 700: + raise ValueError("Excitation wavelength must be between 250 and 700") + if not 250 <= emission_wavelength <= 700: + raise ValueError("Emission wavelength must be between 250 and 700") + + resp = await self.send_command("t", wait_for_char=b"\x06") + assert resp == b"\x06" + + cmd = f"{614220 + int(1000*focal_height)}\x03".encode() + await self.send_command(cmd, purge=False) + + resp = await self.send_command("y", wait_for_char=b"\x06") + assert resp == b"\x06" + await self.send_command(b"08120112207434014351135308559127881772\x03", purge=False) + + resp = await self.send_command("D", wait_for_char=b"\x06") + assert resp == b"\x06" + excitation_wavelength_str = str(excitation_wavelength).zfill(4) + emission_wavelength_str = str(emission_wavelength).zfill(4) + cmd = (f"008401010108120001200100001100100000135000100200200{excitation_wavelength_str}000" + f"{emission_wavelength_str}000000000000000000210011").encode() + checksum = str((sum(cmd)+7) % 100).encode() # don't know why +7 + cmd = cmd + checksum + b"\x03" + await self.send_command(cmd, purge=False) + + resp1 = await self.send_command("O", wait_for_char=b"\x06") + assert resp1 == b"\x06" + resp2 = await self._read_until(b"\x03") + assert resp2 == b"0000\x03" + + body = await self._read_until(b"\x03", timeout=60*2) + assert body is not None + return self._parse_body(body) + + async def _abort(self) -> None: + await self.send_command("x", wait_for_char=None) + + class ShakeType(enum.IntEnum): + LINEAR = 0 + ORBITAL = 1 + + async def shake(self, shake_type: ShakeType) -> None: + """ Warning: the duration for shaking has to be specified on the machine, and the maximum is + 16 minutes. As a hack, we start shaking for the maximum duration every time as long as stop + is not called. """ + max_duration = 16*60 # 16 minutes + + async def shake_maximal_duration(): + """ This method will start the shaking, but returns immediately after + shaking has started. """ + resp = await self.send_command("y", wait_for_char=b"\x06") + assert resp == b"\x06" + await self.send_command(b"08120112207434014351135308559127881422\x03", purge=False) + + resp = await self.send_command("D", wait_for_char=b"\x06") + assert resp == b"\x06" + shake_type_bit = str(shake_type.value) + + duration = str(max_duration).zfill(3) + cmd = f"0033010101010100002000000013{duration}{shake_type_bit}301".encode() + checksum = str((sum(cmd)+73) % 100).encode() # don't know why +73 + cmd = cmd + checksum + b"\x03" + await self.send_command(cmd, purge=False) + + resp = await self.send_command("O", wait_for_char=b"\x06") + assert resp == b"\x06" + resp = await self._read_until(b"\x03") + assert resp == b"0000\x03" + + async def shake_continuous(): + while self._shaking: + await shake_maximal_duration() + + # short sleep allows = frequent checks for fast stopping + seconds_since_start: float = 0 + loop_wait_time = 0.25 + while seconds_since_start < max_duration and self._shaking: + seconds_since_start += loop_wait_time + await asyncio.sleep(loop_wait_time) + + self._shaking = True + self._shaking_task = asyncio.create_task(shake_continuous()) + + async def stop_shaking(self) -> None: + await self._abort() + if self._shaking: + self._shaking = False + if self._shaking_task is not None: + self._shaking_task.cancel() + try: + await self._shaking_task + except asyncio.CancelledError: + pass + self._shaking_task = None diff --git a/pylabrobot/plate_reading/biotek_tests.py b/pylabrobot/plate_reading/biotek_tests.py new file mode 100644 index 0000000000..e9d762d46f --- /dev/null +++ b/pylabrobot/plate_reading/biotek_tests.py @@ -0,0 +1,154 @@ +from typing import Iterator +import unittest +import unittest.mock + +from pylabrobot.plate_reading.biotek_backend import Cytation5Backend + + +def _byte_iter(s: str) -> Iterator[bytes]: + for c in s: + yield c.encode() + + +class TestCytation5Backend(unittest.IsolatedAsyncioTestCase): + """ Tests for the Cytation5Backend. """ + async def asyncSetUp(self): + self.backend = Cytation5Backend(timeout=0.1) + self.backend.dev = unittest.mock.MagicMock() + self.backend.dev.open.return_value = 0 + self.backend.dev.write.return_value = 0 + + async def test_setup(self): + await self.backend.setup() + assert self.backend.dev.open.called + assert self.backend.dev.baudrate == 38400 + self.backend.dev.ftdi_fn.ftdi_set_line_property.assert_called_with(8, 2, 0) + self.backend.dev.ftdi_fn.ftdi_setflowctrl.assert_called_with(0x100) + self.backend.dev.ftdi_fn.ftdi_setrts.assert_called_with(1) + + await self.backend.stop() + assert self.backend.dev.close.called + + async def test_get_serial_number(self): + self.backend.dev.read.side_effect = _byte_iter("\x0600000000 0000\x03") + assert await self.backend.get_serial_number() == "00000000" + + async def test_open(self): + self.backend.dev.read.return_value = b"\x03" + await self.backend.open() + self.backend.dev.write.assert_called_with(b"J") + + async def test_close(self): + self.backend.dev.read.return_value = b"\x03" + await self.backend.close() + self.backend.dev.write.assert_called_with(b"A") + + async def test_get_current_temperature(self): + self.backend.dev.read.side_effect = _byte_iter("\x062360000\x03") + assert await self.backend.get_current_temperature() == 23.6 + + async def test_read_absorbance(self): + self.backend.dev.read.side_effect = _byte_iter( + "\x06" + + "0000\x03" + + "\x06" + + "0350000000000000010000000000490300000\x03" + + "\x06" + + "0000\x03" + + ( + "01,1,\r000:00:00.0,228,01,01,+0.1917,01,02,+0.1225,01,03,+0.0667,01,04,+0.0728,01,05,+0." + "0722,01,06,+0.0664,01,07,+0.0763,01,08,+0.0726,01,09,+0.0825,01,10,+0.1001,01,11,+0.1443" + ",01,12,+0.2105\r\n,02,12,+0.1986,02,11,+0.0800,02,10,+0.0796,02,09,+0.0871,02,08,+0.1059" + ",02,07,+0.0868,02,06,+0.0544,02,05,+0.0644,02,04,+0.0752,02,03,+0.0768,02,02,+0.0925,02" + ",01,+0.0802\r\n,03,01,+0.0925,03,02,+0.1007,03,03,+0.0697,03,04,+0.0736,03,05,+0.0712,03" + ",06,+0.0719,03,07,+0.0710,03,08,+0.0794,03,09,+0.0645,03,10,+0.0799,03,11,+0.0779,03,12," + "+0.1256\r\n,04,12,+0.1525,04,11,+0.0711,04,10,+0.0858,04,09,+0.0753,04,08,+0.0787,04,07," + "+0.0778,04,06,+0.0895,04,05,+0.0733,04,04,+0.0711,04,03,+0.0672,04,02,+0.0719,04,01,+0.0" + "954\r\n,05,01,+0.0841,05,02,+0.0610,05,03,+0.0766,05,04,+0.0773,05,05,+0.0632,05,06,+0.0" + "787,05,07,+0.1100,05,08,+0.0645,05,09,+0.0934,05,10,+0.1439,05,11,+0.1113,05,12,+0.1281" + "\r\n,06,12,+0.1649,06,11,+0.0707,06,10,+0.0892,06,09,+0.0712,06,08,+0.0935,06,07,+0.1079" + ",06,06,+0.0704,06,05,+0.0978,06,04,+0.0596,06,03,+0.0794,06,02,+0.0776,06,01,+0.0930\r\n" + ",07,01,+0.1255,07,02,+0.0742,07,03,+0.0747,07,04,+0.0694,07,05,+0.1004,07,06,+0.0900,07," + "07,+0.0659,07,08,+0.0858,07,09,+0.0876,07,10,+0.0815,07,11,+0.0980,07,12,+0.1329\r\n,08," + "12,+0.1316,08,11,+0.1290,08,10,+0.1103,08,09,+0.0667,08,08,+0.0790,08,07,+0.0602,08,06,+" + "0.0670,08,05,+0.0732,08,04,+0.0657,08,03,+0.0684,08,02,+0.1174,08,01,+0.1427\r\n228\x1a0" + "41\x1a0000\x03" + ) + ) + + resp = await self.backend.read_absorbance(wavelength=580) + + self.backend.dev.write.assert_any_call(b"y") + self.backend.dev.write.assert_any_call(b"08120112207434014351135308559127881772\x03") + self.backend.dev.write.assert_any_call(b"D") + self.backend.dev.write.assert_any_call( + b"004701010108120001200100001100100000106000080580113\x03") + self.backend.dev.write.assert_any_call(b"O") + + assert resp == [ + [0.1917, 0.1225, 0.0667, 0.0728, 0.0722, 0.0664, 0.0763, 0.0726, 0.0825, 0.1001, 0.1443, + 0.2105], + [0.1986, 0.08, 0.0796, 0.0871, 0.1059, 0.0868, 0.0544, 0.0644, 0.0752, 0.0768, 0.0925, + 0.0802], + [0.0925, 0.1007, 0.0697, 0.0736, 0.0712, 0.0719, 0.071, 0.0794, 0.0645, 0.0799, 0.0779, + 0.1256], + [0.1525, 0.0711, 0.0858, 0.0753, 0.0787, 0.0778, 0.0895, 0.0733, 0.0711, 0.0672, 0.0719, + 0.0954], + [0.0841, 0.061, 0.0766, 0.0773, 0.0632, 0.0787, 0.11, 0.0645, 0.0934, 0.1439, 0.1113, 0.1281], + [0.1649, 0.0707, 0.0892, 0.0712, 0.0935, 0.1079, 0.0704, 0.0978, 0.0596, 0.0794, 0.0776, + 0.093], + [0.1255, 0.0742, 0.0747, 0.0694, 0.1004, 0.09, 0.0659, 0.0858, 0.0876, 0.0815, 0.098, 0.1329], + [0.1316, 0.129, 0.1103, 0.0667, 0.079, 0.0602, 0.067, 0.0732, 0.0657, 0.0684, 0.1174, 0.1427]] + + async def test_read_fluorescence(self): + self.backend.dev.read.side_effect = _byte_iter( + "\x06" + + "0000\x03" + + "\x06" + + "0000\x03" + + "\x06" + + "0350000000000000010000000000490300000\x03" + + "\x06" + + "0000\x03" + + ( + "01,1,\r000:00:00.0,227,01,01,0000427,01,02,0000746,01,03,0000598,01,04,0000742,01,05,0001" + "516,01,06,0000704,01,07,0000676,01,08,0000734,01,09,0001126,01,10,0000790,01,11,0000531,0" + "1,12,0000531\r\n,02,12,0002066,02,11,0000541,02,10,0000618,02,09,0000629,02,08,0000891,02" + ",07,0000731,02,06,0000484,02,05,0000576,02,04,0000465,02,03,0000501,02,02,0002187,02,01,0" + "000462\r\n,03,01,0000728,03,02,0000583,03,03,0000472,03,04,0000492,03,05,0000501,03,06,00" + "00491,03,07,0000580,03,08,0000541,03,09,0000556,03,10,0000474,03,11,0000532,03,12,0000522" + "\r\n,04,12,0000570,04,11,0000523,04,10,0000784,04,09,0000441,04,08,0000703,04,07,0000591," + "04,06,0000580,04,05,0000479,04,04,0000474,04,03,0000414,04,02,0000520,04,01,0000427\r\n,0" + "5,01,0000486,05,02,0000422,05,03,0000612,05,04,0000588,05,05,0000805,05,06,0000510,05,07," + "0001697,05,08,0000615,05,09,0001137,05,10,0000653,05,11,0000558,05,12,0000648\r\n,06,12,0" + "000765,06,11,0000487,06,10,0000683,06,09,0001068,06,08,0000721,06,07,0003269,06,06,000067" + "9,06,05,0000532,06,04,0000601,06,03,0000491,06,02,0000538,06,01,0000688\r\n,07,01,0000653" + ",07,02,0000783,07,03,0000522,07,04,0000536,07,05,0000673,07,06,0000858,07,07,0000526,07,0" + "8,0000627,07,09,0000574,07,10,0001993,07,11,0000712,07,12,0000970\r\n,08,12,0000523,08,11" + ",0000607,08,10,0003002,08,09,0000900,08,08,0000697,08,07,0000542,08,06,0000688,08,05,0000" + "622,08,04,0000555,08,03,0000542,08,02,0000742,08,01,0001118\r\n228\x1a091\x1a0000\x03" + ) + ) + + resp = await self.backend.read_fluorescence(excitation_wavelength=485, emission_wavelength=528, + focal_height=7.5) + + self.backend.dev.write.assert_any_call(b"t") + self.backend.dev.write.assert_any_call(b"621720\x03") + self.backend.dev.write.assert_any_call(b"y") + self.backend.dev.write.assert_any_call(b"08120112207434014351135308559127881772\x03") + self.backend.dev.write.assert_any_call(b"D") + self.backend.dev.write.assert_any_call( + b"0084010101081200012001000011001000001350001002002000485000052800000000000000000021001119" + b"\x03") + self.backend.dev.write.assert_any_call(b"O") + + assert resp == [ + [427.0, 746.0, 598.0, 742.0, 1516.0, 704.0, 676.0, 734.0, 1126.0, 790.0, 531.0, 531.0], + [2066.0, 541.0, 618.0, 629.0, 891.0, 731.0, 484.0, 576.0, 465.0, 501.0, 2187.0, 462.0], + [728.0, 583.0, 472.0, 492.0, 501.0, 491.0, 580.0, 541.0, 556.0, 474.0, 532.0, 522.0], + [570.0, 523.0, 784.0, 441.0, 703.0, 591.0, 580.0, 479.0, 474.0, 414.0, 520.0, 427.0], + [486.0, 422.0, 612.0, 588.0, 805.0, 510.0, 1697.0, 615.0, 1137.0, 653.0, 558.0, 648.0], + [765.0, 487.0, 683.0, 1068.0, 721.0, 3269.0, 679.0, 532.0, 601.0, 491.0, 538.0, 688.0], + [653.0, 783.0, 522.0, 536.0, 673.0, 858.0, 526.0, 627.0, 574.0, 1993.0, 712.0, 970.0], + [523.0, 607.0, 3002.0, 900.0, 697.0, 542.0, 688.0, 622.0, 555.0, 542.0, 742.0, 1118.0]] diff --git a/pylabrobot/plate_reading/chatterbox.py b/pylabrobot/plate_reading/chatterbox.py new file mode 100644 index 0000000000..552858a96c --- /dev/null +++ b/pylabrobot/plate_reading/chatterbox.py @@ -0,0 +1,40 @@ +from typing import List +from pylabrobot.plate_reading.backend import PlateReaderBackend + + +class PlateReaderChatterboxBackend(PlateReaderBackend): + """ An abstract class for a plate reader. Plate readers are devices that can read luminescence, + absorbance, or fluorescence from a plate. """ + + def __init__(self): + self.dummy_luminescence = [[0.0]*12]*8 + self.dummy_absorbance = [[0.0]*12]*8 + self.dummy_fluorescence = [[0.0]*12]*8 + + async def setup(self) -> None: + print("Setting up the plate reader.") + + async def stop(self) -> None: + print("Stopping the plate reader.") + + async def open(self) -> None: + print("Opening the plate reader.") + + async def close(self) -> None: + print("Closing the plate reader.") + + async def read_luminescence(self, focal_height: float) -> List[List[float]]: + print(f"Reading luminescence at focal height {focal_height}.") + return self.dummy_luminescence + + async def read_absorbance(self, wavelength: int) -> List[List[float]]: + print(f"Reading absorbance at wavelength {wavelength}.") + return self.dummy_absorbance + + async def read_fluorescence( + self, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float + ) -> List[List[float]]: + return self.dummy_fluorescence diff --git a/pylabrobot/plate_reading/clario_star.py b/pylabrobot/plate_reading/clario_star.py index af1ff74fea..26d5f2e74a 100644 --- a/pylabrobot/plate_reading/clario_star.py +++ b/pylabrobot/plate_reading/clario_star.py @@ -102,6 +102,9 @@ async def send(self, cmd: Union[bytearray, bytes], read_timeout=20): if self.dev is None: raise RuntimeError("device not initialized") + checksum = (sum(cmd) & 0xffff).to_bytes(2, byteorder="big") + cmd = cmd + checksum + b"\x0d" + logger.debug("sending %s", cmd.hex()) w = self.dev.write(cmd) @@ -113,10 +116,6 @@ async def send(self, cmd: Union[bytearray, bytes], read_timeout=20): resp = await self.read_resp(timeout=read_timeout) return resp - async def read_command_status(self): - status = await self.send(bytearray([0x02, 0x00, 0x09, 0x0c, 0x80, 0x00, 0x00, 0x97, 0x0d])) - return status - async def _wait_for_ready_and_return(self, ret, timeout=150): """ Wait for the plate reader to be ready and return the response. """ last_status = None @@ -148,30 +147,31 @@ async def _wait_for_ready_and_return(self, ret, timeout=150): logger.debug("status is ready") return ret + async def read_command_status(self): + status = await self.send(b"\x02\x00\x09\x0c\x80\x00") + return status + async def initialize(self): command_response = await self.send( - bytearray([0x02, 0x00, 0x0D, 0x0C, 0x01, 0x00, 0x00, 0x10, 0x02, 0x00, 0x00, 0x2E, 0x0D])) + b"\x02\x00\x0D\x0C\x01\x00\x00\x10\x02\x00") return await self._wait_for_ready_and_return(command_response) async def request_eeprom_data(self): eeprom_response = await self.send( - bytearray([0x02, 0x00, 0x0F, 0x0C, 0x05, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x29, - 0x0D])) + b"\x02\x00\x0F\x0C\x05\x07\x00\x00\x00\x00\x00\x00") return await self._wait_for_ready_and_return(eeprom_response) async def open(self): - open_response = await self.send(bytearray([0x02, 0x00, 0x0E, 0x0C, 0x03, 0x01, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x20, 0x0D])) + open_response = await self.send(b"\x02\x00\x0E\x0C\x03\x01\x00\x00\x00\x00\x00") return await self._wait_for_ready_and_return(open_response) async def close(self): - close_response = await self.send(bytearray([0x02, 0x00, 0x0E, 0x0C, 0x03, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x1F, 0x0D])) + close_response = await self.send(b"\x02\x00\x0E\x0C\x03\x00\x00\x00\x00\x00\x00") return await self._wait_for_ready_and_return(close_response) async def _mp_and_focus_height_value(self): - mp_and_focus_height_value_response = await self.send(bytearray([0x02, 0x00, 0x0F, 0x0C, 0x05, - 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x39, 0x0D])) + mp_and_focus_height_value_response = await self.send(b"\x02\x00\x0F\x0C\x05\17\x00\x00\x00\x00"+ + b"\x00\x00") return await self._wait_for_ready_and_return(mp_and_focus_height_value_response) async def _run_luminescence(self, focal_height: float): @@ -181,15 +181,13 @@ async def _run_luminescence(self, focal_height: float): focal_height_data = int(focal_height * 100).to_bytes(2, byteorder="big") - # $11 $65 -> $12 $00, some kind of check sum???? - run_response = await self.send(b"\x02\x00\x86\x0c\x04\x31\xec\x21\x66\x05\x96\x04\x60\x2c\x56" b"\x1d\x06\x0c\x08\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x00\x00\x02\x01\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27" b"\x0f\x27\x0f\x01" + focal_height_data + b"\x00\x00\x01\x00\x00\x0e\x10\x00\x01\x00\x01\x00" b"\x01\x00\x01\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01" - b"\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00\x11\x65\x0d") + b"\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00") # TODO: find a prettier way to do this. It's essentially copied from _wait_for_ready_and_return. last_status = None @@ -216,8 +214,7 @@ async def _run_absorbance(self, wavelength: float): b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x00\x00\x82\x02\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1E\x27\x0F\x27" b"\x0F\x19\x01" + wavelength_data + b"\x00\x00\x00\x64\x00\x00\x00\x00\x00\x00\x00\x64\x00" - b"\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x16\x00\x01\x00\x00\x12\xcb" - b"\x0D") + b"\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x16\x00\x01\x00\x00") run_response = await self.send(absorbance_command) # TODO: find a prettier way to do this. It's essentially copied from _wait_for_ready_and_return. @@ -237,18 +234,14 @@ async def _run_absorbance(self, wavelength: float): return run_response async def _read_order_values(self): - return await self.send( - bytearray([0x02, 0x00, 0x0F, 0x0C, 0x05, 0x1D, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3F, - 0x0D])) + return await self.send(b"\x02\x00\x0F\x0C\x05\x1D\x00\x00\x00\x00\x00\x00") async def _status_hw(self): - status_hw_response = await self.send(bytearray([0x02, 0x00, 0x09, 0x0C, 0x81, 0x00, 0x00, 0x98, - 0x0D])) + status_hw_response = await self.send(b"\x02\x00\x09\x0C\x81\x00") return await self._wait_for_ready_and_return(status_hw_response) async def _get_measurement_values(self): - return await self.send(bytearray([0x02, 0x00, 0x0F, 0x0C, 0x05, 0x02, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x24, 0x0D])) + return await self.send(b"\x02\x00\x0F\x0C\x05\x02\x00\x00\x00\x00\x00\x00") async def read_luminescence(self, focal_height: float = 13) -> List[List[float]]: """ Read luminescence values from the plate reader. """ @@ -282,7 +275,7 @@ async def read_luminescence(self, focal_height: float = 13) -> List[List[float]] async def read_absorbance( self, wavelength: int, - report: Literal["OD", "transmittance"] + report: Literal["OD", "transmittance"] = "OD" ) -> List[List[float]]: """ Read absorbance values from the device. @@ -340,3 +333,11 @@ async def read_absorbance( if report == "transmittance": return utils.reshape_2d(transmittance, (8, 12)) + + async def read_fluorescence( + self, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float + ) -> List[List[float]]: + raise NotImplementedError("Not implemented yet") diff --git a/pylabrobot/plate_reading/plate_reader.py b/pylabrobot/plate_reading/plate_reader.py index a17df839c7..a48e713545 100644 --- a/pylabrobot/plate_reading/plate_reader.py +++ b/pylabrobot/plate_reading/plate_reader.py @@ -1,21 +1,16 @@ -import sys from typing import List, Optional, cast -from pylabrobot.machine import Machine, need_setup_finished -from pylabrobot.resources import Coordinate, Plate +from pylabrobot.machines.machine import Machine, need_setup_finished +from pylabrobot.resources import Coordinate, Plate, Resource from pylabrobot.plate_reading.backend import PlateReaderBackend - -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal +from pylabrobot.resources.resource_holder import ResourceHolderMixin class NoPlateError(Exception): pass -class PlateReader(Machine): +class PlateReader(ResourceHolderMixin, Machine): """ The front end for plate readers. Plate readers are devices that can read luminescence, absorbance, or fluorescence from a plate. @@ -45,12 +40,14 @@ def __init__( category=category, model=model) self.backend: PlateReaderBackend = backend # fix type - def assign_child_resource(self, resource): + def assign_child_resource(self, resource: Resource, location: Optional[Coordinate]=None, + reassign: bool = True): if len(self.children) >= 1: raise ValueError("There already is a plate in the plate reader.") if not isinstance(resource, Plate): raise ValueError("The resource must be a Plate.") - super().assign_child_resource(resource, location=Coordinate.zero()) + + super().assign_child_resource(resource, location=location, reassign=reassign) def get_plate(self) -> Plate: if len(self.children) == 0: @@ -74,19 +71,30 @@ async def read_luminescence(self, focal_height: float) -> List[List[float]]: return await self.backend.read_luminescence(focal_height=focal_height) @need_setup_finished - async def read_absorbance( - self, - wavelength: int, - report: Literal["OD", "transmittance"] - ) -> List[List[float]]: - """ Read the absorbance from the plate in either OD or transmittance. + async def read_absorbance(self, wavelength: int) -> List[List[float]]: + """ Read the absorbance from the plate in OD, unless otherwise specified by the backend. Args: wavelength: The wavelength to read the absorbance at, in nanometers. - report: Whether to report the absorbance in OD or transmittance. """ - if report not in {"OD", "transmittance"}: - raise ValueError("report must be either 'OD' or 'transmittance'.") + return await self.backend.read_absorbance(wavelength=wavelength) + + @need_setup_finished + async def read_fluorescence( + self, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float + ) -> List[List[float]]: + """ + + Args: + excitation_wavelength: The excitation wavelength to read the fluorescence at, in nanometers. + emission_wavelength: The emission wavelength to read the fluorescence at, in nanometers. + focal_height: The focal height to read the fluorescence at, in micrometers. + """ - return await self.backend.read_absorbance(wavelength=wavelength, report=report) + return await self.backend.read_fluorescence(excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height) diff --git a/pylabrobot/plate_reading/plate_reader_tests.py b/pylabrobot/plate_reading/plate_reader_tests.py index d71a1db9d3..c8778470c6 100644 --- a/pylabrobot/plate_reading/plate_reader_tests.py +++ b/pylabrobot/plate_reading/plate_reader_tests.py @@ -1,5 +1,3 @@ -""" Tests for plate reader """ - import unittest from pylabrobot.plate_reading import PlateReader @@ -22,12 +20,16 @@ async def open(self): async def close(self): pass - async def read_luminescence(self): + async def read_luminescence(self, focal_height: float): return [[1, 2, 3], [4, 5, 6]] - async def read_absorbance(self): + async def read_absorbance(self, wavelength: int): return [[1, 2, 3], [4, 5, 6]] + async def read_fluorescence(self, excitation_wavelength: int, emission_wavelength: int, + focal_height: float): + raise NotImplementedError + class TestPlateReaderResource(unittest.TestCase): """ Test plate reade as a resource. """ @@ -37,21 +39,19 @@ def setUp(self) -> None: self.pr = PlateReader(name="pr", backend=MockPlateReaderBackend(), size_x=1, size_y=1, size_z=1) def test_add_plate(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=1, - items=[]) + plate = Plate("plate", size_x=1, size_y=1, size_z=1, ordered_items={}) self.pr.assign_child_resource(plate) def test_add_plate_full(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=1, - items=[]) + plate = Plate("plate", size_x=1, size_y=1, size_z=1, ordered_items={}) self.pr.assign_child_resource(plate) - another_plate = Plate("another_plate", size_x=1, size_y=1, size_z=1, lid_height=1, items=[]) + another_plate = Plate("another_plate", size_x=1, size_y=1, size_z=1, ordered_items={}) with self.assertRaises(ValueError): self.pr.assign_child_resource(another_plate) def test_get_plate(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=1, items=[]) + plate = Plate("plate", size_x=1, size_y=1, size_z=1, ordered_items={}) self.pr.assign_child_resource(plate) self.assertEqual(self.pr.get_plate(), plate) diff --git a/pylabrobot/powder_dispensing/backend.py b/pylabrobot/powder_dispensing/backend.py index 770703b00b..eb3e83468c 100644 --- a/pylabrobot/powder_dispensing/backend.py +++ b/pylabrobot/powder_dispensing/backend.py @@ -2,7 +2,7 @@ from abc import ABCMeta, abstractmethod from typing import List -from pylabrobot.machine import MachineBackend +from pylabrobot.machines.backends import MachineBackend from pylabrobot.resources import Resource, Powder diff --git a/pylabrobot/powder_dispensing/chatterbox.py b/pylabrobot/powder_dispensing/chatterbox.py new file mode 100644 index 0000000000..b9efd403ee --- /dev/null +++ b/pylabrobot/powder_dispensing/chatterbox.py @@ -0,0 +1,25 @@ +from typing import List + +from pylabrobot.powder_dispensing.backend import ( + PowderDispenserBackend, + PowderDispense, + DispenseResults +) + + +class PowderDispenserChatterboxBackend(PowderDispenserBackend): + """ Chatter box backend for device-free testing. Prints out all operations. """ + + async def setup(self) -> None: + print("Setting up the powder dispenser.") + + async def stop(self) -> None: + print("Stopping the powder dispenser.") + + async def dispense( + self, + dispense_parameters: List[PowderDispense], + **backend_kwargs + ) -> List[DispenseResults]: + print(f"Dispensing {len(dispense_parameters)} powders.") + return [DispenseResults(actual_amount=dispense.amount) for dispense in dispense_parameters] diff --git a/pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py b/pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py index df34145371..64399bca1b 100644 --- a/pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py +++ b/pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py @@ -6,3 +6,12 @@ class CrystalPowderdose(PowderDispenserBackend): def __init__(self, arksuite_adress: str) -> None: self.arksuite_adress = arksuite_adress + async def setup(self) -> None: + raise NotImplementedError("CrystalPowderdose not implemented yet") + + async def stop(self) -> None: + raise NotImplementedError("CrystalPowderdose not implemented yet") + + def serialize(self) -> dict: + return {**super().serialize(), "arksuite_adress": self.arksuite_adress} + diff --git a/pylabrobot/powder_dispensing/powder_dispenser.py b/pylabrobot/powder_dispensing/powder_dispenser.py index 141e1c1e0b..4894acdbf5 100644 --- a/pylabrobot/powder_dispensing/powder_dispenser.py +++ b/pylabrobot/powder_dispensing/powder_dispenser.py @@ -1,5 +1,5 @@ from typing import Any, Dict, List, Optional, Sequence, Union, cast -from pylabrobot.machine import Machine, need_setup_finished +from pylabrobot.machines.machine import Machine, need_setup_finished from .backend import PowderDispenserBackend, PowderDispense from pylabrobot.resources import Resource, Powder @@ -80,7 +80,3 @@ async def dispense( result = await self.backend.dispense(powder_dispenses, **backend_kwargs) return cast(List[Dict[str, Any]], result) - - async def setup(self): - """ Setup the powder dispenser. This method should be called before any dispensing actions. """ - await super().setup() diff --git a/pylabrobot/powder_dispensing/powder_dispenser_tests.py b/pylabrobot/powder_dispensing/powder_dispenser_tests.py index 46a9ff19e2..fa45f58294 100644 --- a/pylabrobot/powder_dispensing/powder_dispenser_tests.py +++ b/pylabrobot/powder_dispensing/powder_dispenser_tests.py @@ -2,9 +2,10 @@ from unittest.mock import AsyncMock from pylabrobot.powder_dispensing.powder_dispenser import PowderDispenser from pylabrobot.powder_dispensing.backend import PowderDispenserBackend, PowderDispense, DispenseResults -from pylabrobot.resources import Powder, Cos_96_DW_1mL +from pylabrobot.resources import Powder, Cor_96_wellplate_360ul_Fb from typing import List + class MockPowderDispenserBackend(PowderDispenserBackend): """ A mock backend for testing. """ async def setup(self) -> None: @@ -37,13 +38,13 @@ async def asyncSetUp(self) -> None: await self.dispenser.setup() async def test_dispense_single_resource(self): - plate = Cos_96_DW_1mL(name="test_resource") + plate = Cor_96_wellplate_360ul_Fb(name="test_resource") powder = Powder("salt") await self.dispenser.dispense(plate["A1"], powder, 0.005) self.backend.dispense.assert_called_once() async def test_dispense_multiple_resources(self): - plate = Cos_96_DW_1mL(name="test_resource") + plate = Cor_96_wellplate_360ul_Fb(name="test_resource") resources = [plate["A1"], plate["A2"]] powders = [Powder("salt"), Powder("salt")] amounts = [0.005, 0.010] @@ -51,7 +52,7 @@ async def test_dispense_multiple_resources(self): self.assertEqual(self.backend.dispense.call_count, 1) async def test_dispense_parameters_handling(self): - plate = Cos_96_DW_1mL(name="test_resource") + plate = Cor_96_wellplate_360ul_Fb(name="test_resource") powder = Powder("salt") dispense_parameters = {"param1": "value1"} await self.dispenser.dispense( @@ -61,12 +62,12 @@ async def test_dispense_parameters_handling(self): async def test_assertion_for_mismatched_lengths(self): with self.assertRaises(AssertionError): - plate = Cos_96_DW_1mL(name="test_resource") + plate = Cor_96_wellplate_360ul_Fb(name="test_resource") list_of_powders = [Powder("salt"), Powder("salt")] await self.dispenser.dispense(plate["A1"], list_of_powders, [0.005]) with self.assertRaises(AssertionError): - plate = Cos_96_DW_1mL(name="test_resource") + plate = Cor_96_wellplate_360ul_Fb(name="test_resource") await self.dispenser.dispense( plate["A1"], Powder("salt"), diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump.py b/pylabrobot/pumps/agrowpumps/agrowdosepump.py index abf3b7fc9d..cb9c6c4079 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump.py +++ b/pylabrobot/pumps/agrowpumps/agrowdosepump.py @@ -2,7 +2,7 @@ import threading import time import asyncio -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Union from pymodbus.client import AsyncModbusSerialClient # type: ignore @@ -28,9 +28,13 @@ class AgrowPumpArray(PumpArrayBackend): pump_index_to_address: A dictionary that maps pump indices to their Modbus addresses. """ - def __init__(self, port: str, address: int): + def __init__(self, port: str, address: Union[int, str]): + if not isinstance(port, str): + raise ValueError("Port must be a string") self.port = port - self.address = address + if address not in range(0, 256): + raise ValueError("Pump address out of range") + self.address = int(address) self._keep_alive_thread: Optional[threading.Thread] = None self._pump_index_to_address: Optional[Dict[int, int]] = None self._modbus: Optional[AsyncModbusSerialClient] = None @@ -121,6 +125,9 @@ async def _setup_modbus(self): if not self.modbus.connected: raise ConnectionError("Modbus connection failed during pump setup") + def serialize(self): + return {**super().serialize(), "port": self.port, "address": self.address} + async def run_revolutions(self, num_revolutions: List[float], use_channels: List[int]): """ Run the specified channels at the speed selected. If speed is 0, the pump will be halted. diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py b/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py index 18d6d1d68a..d861e64561 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py +++ b/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py @@ -26,7 +26,7 @@ async def connect(self): def connected(self): return self._connected - async def read_holding_registers(self, address, count, **kwargs): + async def read_holding_registers(self, address: int, count: int, **kwargs): # type: ignore # pylint: disable=invalid-overridden-method """ Simulates reading holding registers from the AgrowPumpArray. """ if "unit" not in kwargs: @@ -38,7 +38,7 @@ async def read_holding_registers(self, address, count, **kwargs): write_register = AsyncMock() - def close(self): + def close(self, reconnect = False): assert not self.connected, "Modbus connection not established" self._connected = False @@ -54,7 +54,8 @@ async def _mock_setup_modbus(): # pylint: disable=protected-access self.agrow_backend._setup_modbus = _mock_setup_modbus # type: ignore[method-assign] - self.pump_array = PumpArray(backend=self.agrow_backend) + self.pump_array = PumpArray(backend=self.agrow_backend, name="test_pump_array", size_x=0, + size_y=0, size_z=0, calibration=None) await self.pump_array.setup() async def asyncTearDown(self): diff --git a/pylabrobot/pumps/backend.py b/pylabrobot/pumps/backend.py index 8c854c39f3..65b820ceb3 100644 --- a/pylabrobot/pumps/backend.py +++ b/pylabrobot/pumps/backend.py @@ -1,7 +1,7 @@ from abc import ABCMeta, abstractmethod from typing import List -from pylabrobot.machine import MachineBackend +from pylabrobot.machines.backends import MachineBackend class PumpBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/pumps/calibration.py b/pylabrobot/pumps/calibration.py index f8dbd6efa5..203b8af77e 100644 --- a/pylabrobot/pumps/calibration.py +++ b/pylabrobot/pumps/calibration.py @@ -87,6 +87,16 @@ def load_calibration( raise NotImplementedError("Calibration filetype not supported.") raise NotImplementedError("Calibration format not supported.") + def serialize(self) -> dict: + return { + "calibration": self.calibration, + "calibration_mode": self.calibration_mode + } + + @classmethod + def deserialize(cls, data: dict) -> PumpCalibration: + return cls(calibration=data["calibration"], calibration_mode=data["calibration_mode"]) + @classmethod def load_from_json( cls, diff --git a/pylabrobot/pumps/chatterbox.py b/pylabrobot/pumps/chatterbox.py new file mode 100644 index 0000000000..1678d737f5 --- /dev/null +++ b/pylabrobot/pumps/chatterbox.py @@ -0,0 +1,48 @@ +from typing import List + +from pylabrobot.pumps.backend import PumpBackend, PumpArrayBackend + + +class PumpChatterboxBackend(PumpBackend): + """ Chatter box backend for device-free testing. Prints out all operations. """ + + async def setup(self): + print("Setting up the pump.") + + async def stop(self): + print("Stopping the pump.") + + def run_revolutions(self, num_revolutions: float): + print(f"Running {num_revolutions} revolutions.") + + def run_continuously(self, speed: float): + print(f"Running continuously at speed {speed}.") + + def halt(self): + print("Halting the pump.") + + +class PumpArrayChatterboxBackend(PumpArrayBackend): + """ Chatter box backend for device-free testing. Prints out all operations. """ + + def __init__(self, num_channels: int = 8) -> None: + self._num_channels = num_channels + + async def setup(self): + print("Setting up the pump array.") + + async def stop(self): + print("Stopping the pump array.") + + @property + def num_channels(self) -> int: + return self._num_channels + + async def run_revolutions(self, num_revolutions: List[float], use_channels: List[int]): + print(f"Running {num_revolutions} revolutions on channels {use_channels}.") + + async def run_continuously(self, speed: List[float], use_channels: List[int]): + print(f"Running continuously at speed {speed} on channels {use_channels}.") + + async def halt(self): + print("Halting the pump array.") diff --git a/pylabrobot/pumps/cole_parmer/masterflex.py b/pylabrobot/pumps/cole_parmer/masterflex.py index 40f3965991..5decd01809 100644 --- a/pylabrobot/pumps/cole_parmer/masterflex.py +++ b/pylabrobot/pumps/cole_parmer/masterflex.py @@ -31,6 +31,9 @@ async def setup(self): self.ser.write(b"\x05") # Enquiry; ready to send. self.ser.write(b"\x05P02\r") + def serialize(self): + return { **super().serialize(), "com_port": self.com_port} + async def stop(self): assert self.ser is not None, "Pump not initialized" self.ser.close() diff --git a/pylabrobot/pumps/pump.py b/pylabrobot/pumps/pump.py index 5d279df23c..bcfa2d8b5e 100644 --- a/pylabrobot/pumps/pump.py +++ b/pylabrobot/pumps/pump.py @@ -1,7 +1,7 @@ import asyncio from typing import Optional, Union -from pylabrobot.machine import Machine +from pylabrobot.machines.machine import Machine from .backend import PumpBackend from .calibration import PumpCalibration @@ -33,6 +33,20 @@ def __init__( raise ValueError("Calibration may only have a single item for this pump") self.calibration = calibration + def serialize(self) -> dict: + if self.calibration is None: + return super().serialize() + return {**super().serialize(), "calibration": self.calibration.serialize()} + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False): + data_copy = data.copy() + calibration_data = data_copy.pop("calibration", None) + if calibration_data is not None: + calibration = PumpCalibration.deserialize(calibration_data) + data_copy["calibration"] = calibration + return super().deserialize(data_copy, allow_marshal=allow_marshal) + async def run_revolutions(self, num_revolutions: float): """ Run a given number of revolutions. This method will return after the command has been sent, and the pump will run until `halt` is called. diff --git a/pylabrobot/pumps/pump_tests.py b/pylabrobot/pumps/pump_tests.py index 543a000488..1ab906e9f2 100644 --- a/pylabrobot/pumps/pump_tests.py +++ b/pylabrobot/pumps/pump_tests.py @@ -41,7 +41,10 @@ def setUp(self): async def asyncSetUp(self) -> None: await super().asyncSetUp() - self.pump_array = PumpArray(backend=self.mock_backend, calibration=None) + self.pump_array = PumpArray(backend=self.mock_backend, + calibration=None, + size_x=0, size_y=0, size_z=0, + name="pump_array") await self.pump_array.setup() async def asyncTearDown(self) -> None: @@ -99,7 +102,7 @@ async def test_run_invalid_duration(self): async def test_volume_pump_duration(self): self.pump_array.calibration = self.test_calibration - self.pump_array.calibration_mode = "duration" + self.pump_array.calibration.calibration_mode = "duration" # valid: can use an int or float self.pump_array.run_for_duration = AsyncMock() # type: ignore[method-assign] @@ -108,7 +111,7 @@ async def test_volume_pump_duration(self): async def test_volume_pump_revolutions(self): self.pump_array.calibration = self.test_calibration - self.pump_array.calibration_mode = "revolutions" + self.pump_array.calibration.calibration_mode = "revolutions" # valid: can use an int or float self.pump_array.run_revolutions = AsyncMock() # type: ignore[method-assign] diff --git a/pylabrobot/pumps/pumparray.py b/pylabrobot/pumps/pumparray.py index 0269431cde..e68cf595a5 100644 --- a/pylabrobot/pumps/pumparray.py +++ b/pylabrobot/pumps/pumparray.py @@ -1,7 +1,7 @@ import asyncio -from typing import Union, Optional, List, Literal +from typing import Union, Optional, List -from pylabrobot.machine import Machine +from pylabrobot.machines.machine import Machine from pylabrobot.pumps.backend import PumpArrayBackend from pylabrobot.pumps.errors import NotCalibratedError from pylabrobot.pumps.calibration import PumpCalibration @@ -18,12 +18,28 @@ class PumpArray(Machine): num_channels: The number of channels that the pump array has. """ - def __init__(self, backend: PumpArrayBackend, calibration: Optional[PumpCalibration] = None): - self._setup_finished = False - self.backend: PumpArrayBackend = backend + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: PumpArrayBackend, + category: Optional[str] = None, + model: Optional[str] = None, + calibration: Optional[PumpCalibration] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + backend=backend, + category=category, + model=model, + ) + self.backend: PumpArrayBackend = backend # fix type self.calibration = calibration - self.calibration_mode: Optional[Literal["duration", "revolutions"]] = \ - calibration.calibration_mode if calibration else None @property def num_channels(self) -> int: @@ -35,6 +51,20 @@ def num_channels(self) -> int: return self.backend.num_channels + def serialize(self) -> dict: + if self.calibration is None: + return super().serialize() + return {**super().serialize(), "calibration": self.calibration.serialize()} + + @classmethod + def deserialize(cls, data: dict, allow_marshal: bool = False): + data_copy = data.copy() + calibration_data = data_copy.pop("calibration", None) + if calibration_data is not None: + calibration = PumpCalibration.deserialize(calibration_data) + data_copy["calibration"] = calibration + return super().deserialize(data_copy, allow_marshal=allow_marshal) + async def run_revolutions(self, num_revolutions: Union[float, List[float]], use_channels: Union[int, List[int]]): """ Run the specified channels for the specified number of revolutions. @@ -127,7 +157,7 @@ async def pump_volume(self, speed: Union[float, int, List[float], List[int]], raise ValueError("Volume must be positive.") if not len(speed) == len(use_channels) == len(volume): raise ValueError("Speed, use_channels, and volume must be the same length.") - if self.calibration_mode == "duration": + if self.calibration.calibration_mode == "duration": durations = [channel_volume / self.calibration[channel] for channel, channel_volume in zip(use_channels, volume)] tasks = [asyncio.create_task( @@ -135,7 +165,7 @@ async def pump_volume(self, speed: Union[float, int, List[float], List[int]], use_channels=channel, duration=duration)) for channel_speed, channel, duration in zip(speed, use_channels, durations)] - elif self.calibration_mode == "revolutions": + elif self.calibration.calibration_mode == "revolutions": num_rotations = [channel_volume / self.calibration[channel] for channel, channel_volume in zip(use_channels, volume)] tasks = [asyncio.create_task( diff --git a/pylabrobot/resources/__init__.py b/pylabrobot/resources/__init__.py index 2aca473ab7..941bacef2f 100644 --- a/pylabrobot/resources/__init__.py +++ b/pylabrobot/resources/__init__.py @@ -11,27 +11,36 @@ from .coordinate import Coordinate from .deck import Deck from .errors import ResourceNotFoundError -from .itemized_resource import ItemizedResource, create_equally_spaced +from .itemized_resource import ItemizedResource from .liquid import Liquid from .petri_dish import PetriDish, PetriDishHolder from .plate import Plate, Lid, Well +from .plate_adapter import PlateAdapter from .powder import Powder -from .resource import Resource, get_resource_class_from_string +from .resource import Resource from .tip_rack import TipRack, TipSpot from .trash import Trash +from .trough import Trough from .tube import Tube from .tube_rack import TubeRack +from .utils import ( + create_equally_spaced_x, + create_equally_spaced_y, + create_equally_spaced_2d, + create_ordered_items_2d) from .tip_tracker import TipTracker, does_tip_tracking, no_tip_tracking, set_tip_tracking -from .volume_tracker import VolumeTracker, does_volume_tracking, no_volume_tracking, set_volume_tracking +from .volume_tracker import VolumeTracker, does_volume_tracking, no_volume_tracking, set_volume_tracking, does_cross_contamination_tracking, no_cross_contamination_tracking, set_cross_contamination_tracking from .resource_stack import ResourceStack # labware manufacturers and suppliers +from .alpaqua import * from .azenta import * from .boekel import * from .corning_costar import * from .corning_axygen import * +from .eppendorf import * from .falcon import * from .greiner import * from .hamilton import * diff --git a/pylabrobot/resources/agenbio/plates.py b/pylabrobot/resources/agenbio/plates.py new file mode 100644 index 0000000000..515d3032f6 --- /dev/null +++ b/pylabrobot/resources/agenbio/plates.py @@ -0,0 +1,106 @@ +from typing import Optional + +from pylabrobot.resources.height_volume_functions import ( + compute_height_from_volume_rectangle, + compute_volume_from_height_rectangle) +from pylabrobot.resources.plate import Lid, Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import CrossSectionType, Well, WellBottomType + + +def AGenBio_4_wellplate_Vb(name: str, lid: Optional[Lid] = None) -> Plate: + """ + AGenBio Catalog No. RES-75-4MW + - Material: Polypropylene + - Max. volume: 75 mL + """ + INNER_WELL_WIDTH = 26.1 # measured + INNER_WELL_LENGTH = 71.2 # measured + + well_kwargs = { + "size_x": 26, # measured + "size_y": 71.2, # measured + "size_z": 42.55, # measured to bottom of well + "bottom_type": WellBottomType.FLAT, + "cross_section_type": CrossSectionType.RECTANGLE, + "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle( + liquid_volume, + INNER_WELL_LENGTH, + INNER_WELL_WIDTH, + ), + "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle( + liquid_height, + INNER_WELL_LENGTH, + INNER_WELL_WIDTH, + ), + "material_z_thickness": 1, + } + + return Plate( + name=name, + size_x=127.76, # from spec + size_y=85.48, # from spec + size_z=43.80, # measured + lid=lid, + model=AGenBio_4_wellplate_Vb.__name__, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=4, + num_items_y=1, + dx=9.8, # measured + dy=7.2, # measured + dz=0.9, # measured + item_dx=INNER_WELL_WIDTH + 1, # 1 mm wall thickness + item_dy=INNER_WELL_LENGTH, + **well_kwargs, + ), + ) + + +def AGenBio_1_wellplate_Fl(name: str, lid: Optional[Lid] = None) -> Plate: + """ + AGenBio Catalog No. RES-190-F + - Material: Polypropylene + - Max. volume: 190 mL + """ + INNER_WELL_WIDTH = 107.2 # measured + INNER_WELL_HEIGHT = 70.9 # measured + + well_kwargs = { + "size_x": INNER_WELL_WIDTH, # measured + "size_y": INNER_WELL_HEIGHT, # measured + "size_z": 24.76, # measured to bottom of well + "bottom_type": WellBottomType.FLAT, + "cross_section_type": CrossSectionType.RECTANGLE, + "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_rectangle( + liquid_volume, + INNER_WELL_HEIGHT, + INNER_WELL_WIDTH, + ), + "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_rectangle( + liquid_height, + INNER_WELL_HEIGHT, + INNER_WELL_WIDTH, + ), + "material_z_thickness": 5.88, + } + + return Plate( + name=name, + size_x=127.76, # from spec + size_y=85.48, # from spec + size_z=31.4, # from spec + lid=lid, + model=AGenBio_1_wellplate_Fl.__name__, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=1, + num_items_y=1, + dx=9.8, + dy=7.6, + dz=5.88, + item_dx=INNER_WELL_WIDTH, + item_dy=INNER_WELL_HEIGHT, + **well_kwargs, + ), + ) diff --git a/pylabrobot/resources/azenta/plates.py b/pylabrobot/resources/azenta/plates.py index e3d96be1b5..2372a0a429 100644 --- a/pylabrobot/resources/azenta/plates.py +++ b/pylabrobot/resources/azenta/plates.py @@ -2,57 +2,86 @@ # pylint: disable=invalid-name -from pylabrobot.resources.plate import Plate +from pylabrobot.resources.plate import Lid, Plate from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType -from pylabrobot.resources.itemized_resource import create_equally_spaced +from pylabrobot.resources.utils import create_ordered_items_2d -from pylabrobot.resources.volume_functions import calculate_liquid_volume_container_2segments_round_vbottom +from pylabrobot.resources.height_volume_functions import calculate_liquid_volume_container_2segments_round_vbottom -def _compute_volume_from_height_FrameStar_96_wellplate_skirted(h: float): - if h > 42.5: - raise ValueError(f"Height {h} is too large for Azenta4titudeFrameStar_96_wellplate_skirted") +def _compute_volume_from_height_Azenta4titudeFrameStar_96_wellplate_200ul_Vb(h: float): + if h > 15.1: + raise ValueError(f"Height {h} is too large for Azenta4titudeFrameStar_96_wellplate_200ul_Vb") return calculate_liquid_volume_container_2segments_round_vbottom( d=5.5, h_cone=9.8, - h_cylinder=5.2, + h_cylinder=5.3, liquid_height=h) +def Azenta4titudeFrameStar_96_wellplate_200ul_Vb_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.76, + # size_y=85.48, + # size_z=5, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Azenta4titudeFrameStar_96_wellplate_200ul_Vb_Lid", + # ) + -#: Azenta4titudeFrameStar_96_wellplate_skirted def Azenta4titudeFrameStar_96_wellplate_skirted(name: str, with_lid: bool = False) -> Plate: - # https://www.azenta.com/products/framestar-96-well-skirted-pcr-plate#specifications + raise NotImplementedError("This function is deprecated and will be removed in a future version." + " Use 'Azenta4titudeFrameStar_96_wellplate_200ul_Vb' instead.") + + +#: Azenta4titudeFrameStar_96_wellplate_skirted +def Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name: str, with_lid: bool = False) -> Plate: + """ Azenta cat. no.: 4ti-0960. + - Material: Polypropylene wells, polycarbonate frame + - Sterilization compatibility: ? + - Chemical resistance: ? + - Thermal resistance: ? + - Sealing options: ? + - Cleanliness: ? + - Automation compatibility: "Rigid frame eliminates warping and distortion during + PCR. Ideal for use with robotic systems.' -> extra rigid skirt option (4ti-0960/RIG) + available. + """ return Plate( name=name, - size_x=127.0, - size_y=86.0, - size_z=16, - with_lid=with_lid, - model="Azenta4titudeFrameStar_96_wellplate_skirted", - lid_height=5, - items=create_equally_spaced(Well, + size_x=127.76, + size_y=85.48, + size_z=16.1, + lid=Azenta4titudeFrameStar_96_wellplate_200ul_Vb_Lid(name + "_lid") if with_lid else None, + model="Azenta4titudeFrameStar_96_wellplate_200ul_Vb", + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=11.0, - dy=8.7, - dz=1.54, + dy=8.49, + dz=0.8, item_dx=9, item_dy=9, size_x=5.5, size_y=5.5, - size_z=15, + size_z=15.1, bottom_type=WellBottomType.V, - compute_volume_from_height=_compute_volume_from_height_FrameStar_96_wellplate_skirted, + material_z_thickness=0.73, + compute_volume_from_height=( + _compute_volume_from_height_Azenta4titudeFrameStar_96_wellplate_200ul_Vb + ), cross_section_type=CrossSectionType.CIRCLE ), ) -#: Azenta4titudeFrameStar_96_wellplate_skirted_L -def Azenta4titudeFrameStar_96_wellplate_skirted_L(name: str, with_lid: bool = False) -> Plate: - return Azenta4titudeFrameStar_96_wellplate_skirted(name=name, with_lid=with_lid) +#: Azenta4titudeFrameStar_96_wellplate_Vb_L +def Azenta4titudeFrameStar_96_wellplate_200ul_Vb_L(name: str, with_lid: bool = False) -> Plate: + return Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=name, with_lid=with_lid) -#: Azenta4titudeFrameStar_96_wellplate_skirted_P -def Azenta4titudeFrameStar_96_wellplate_skirted_P(name: str, with_lid: bool = False) -> Plate: - return Azenta4titudeFrameStar_96_wellplate_skirted(name=name, with_lid=with_lid).rotated(90) +#: Azenta4titudeFrameStar_96_wellplate_Vb_P +def Azenta4titudeFrameStar_96_wellplate_200ul_Vb_P(name: str, with_lid: bool = False) -> Plate: + return Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=name, with_lid=with_lid).rotated(z=90) diff --git a/pylabrobot/resources/boekel/tube_carriers.py b/pylabrobot/resources/boekel/tube_carriers.py index 5f7277436c..3d6598df77 100644 --- a/pylabrobot/resources/boekel/tube_carriers.py +++ b/pylabrobot/resources/boekel/tube_carriers.py @@ -1,5 +1,5 @@ +from pylabrobot.resources.carrier import CarrierSite, TubeCarrier, create_carrier_sites from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.carrier import TubeCarrier, create_carrier_sites def boekel_50mL_falcon_carrier(name: str) -> TubeCarrier: # pylint: disable=invalid-name @@ -18,7 +18,7 @@ def boekel_50mL_falcon_carrier(name: str) -> TubeCarrier: # pylint: disable=inva size_x=174, size_y=52, size_z=95, - sites=create_carrier_sites( + sites=create_carrier_sites(klass=CarrierSite, locations=[Coordinate(x=x, y=11, z=5) for x in [11, 46, 91, 127]], site_size_x=[30]*4, site_size_y=[30]*4 @@ -43,7 +43,7 @@ def boekel_15mL_falcon_carrier(name: str) -> TubeCarrier: # pylint: disable=inva size_x=174, size_y=52, size_z=95, - sites=create_carrier_sites( + sites=create_carrier_sites(klass=CarrierSite, locations=[Coordinate(x=x, y=27, z=5) for x in [5, 34, 63, 88, 118, 147]] + [Coordinate(x=x, y=4.5, z=5) for x in [5, 34, 63, 88, 118, 147]], site_size_x=[17]*16, @@ -71,7 +71,7 @@ def boekel_1_5mL_microcentrifuge_carrier(name: str) -> TubeCarrier: # pylint: di size_x=174, size_y=52, size_z=95, - sites=create_carrier_sites( + sites=create_carrier_sites(klass=CarrierSite, locations=[Coordinate(x=x, y=57, z=5) for x in x_locs] + [Coordinate(x=x, y=48, z=5) for x in x_locs] + [Coordinate(x=x, y=39, z=5) for x in x_locs] + @@ -101,7 +101,7 @@ def boekel_mini_microcentrifuge_carrier(name: str) -> TubeCarrier: # pylint: dis size_x=174, size_y=52, size_z=95, - sites=create_carrier_sites( + sites=create_carrier_sites(klass=CarrierSite, locations=[Coordinate(x=x, y=68.5, z=5) for x in x_locs] + [Coordinate(x=x, y=50, z=5) for x in x_locs] + [Coordinate(x=x, y=31, z=5) for x in x_locs] + diff --git a/pylabrobot/resources/carrier.py b/pylabrobot/resources/carrier.py index 8954ba1f1f..ad6b5e41d2 100644 --- a/pylabrobot/resources/carrier.py +++ b/pylabrobot/resources/carrier.py @@ -1,32 +1,36 @@ from __future__ import annotations import logging -from typing import List, Optional, Union +from typing import Generic, List, Optional, Type, TypeVar, Union + +from pylabrobot.resources.resource_holder import ResourceHolderMixin from .coordinate import Coordinate +from .plate import Plate from .resource import Resource - +from .resource_stack import ResourceStack +from .plate_adapter import PlateAdapter logger = logging.getLogger("pylabrobot") -class CarrierSite(Resource): +class CarrierSite(ResourceHolderMixin, Resource): """ A single site within a carrier. """ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, category: str = "carrier_site", model: Optional[str] = None): - super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, category=category, - model=model) + super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, + category=category, model=model) self.resource: Optional[Resource] = None def assign_child_resource( self, resource: Resource, - location: Coordinate = Coordinate.zero(), + location: Optional[Coordinate] = None, reassign: bool = True ): self.resource = resource - return super().assign_child_resource(resource, location=location) + return super().assign_child_resource(resource, location, reassign) def unassign_child_resource(self, resource): self.resource = None @@ -35,8 +39,10 @@ def unassign_child_resource(self, resource): def __eq__(self, other): return super().__eq__(other) and self.resource == other.resource +S = TypeVar("S", bound=Resource) -class Carrier(Resource): + +class Carrier(Resource, Generic[S]): """ Abstract base resource for carriers. It is recommended to always use a resource carrier to store resources, because this ensures the @@ -74,7 +80,7 @@ def __init__( self, name: str, size_x: float, size_y: float, size_z: float, - sites: Optional[List[CarrierSite]] = None, + sites: Optional[List[S]] = None, category: Optional[str] = "carrier", model: Optional[str] = None): super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, category=category, @@ -119,8 +125,7 @@ def assign_resource_to_site(self, resource: Resource, spot: int): raise IndexError(f"Invalid spot {spot}") if self.sites[spot].resource is not None: raise ValueError(f"spot {spot} already has a resource") - - self.sites[spot].assign_child_resource(resource, location=Coordinate.zero()) + self.sites[spot].assign_child_resource(resource) def unassign_child_resource(self, resource): """ Unassign a resource from this carrier, checked by name. @@ -168,7 +173,9 @@ def __eq__(self, other): class TipCarrier(Carrier): - """ Base class for tip carriers. """ + r""" Base class for tip carriers. + Name prefix: 'TIP\_' + """ def __init__( self, name: str, @@ -179,18 +186,98 @@ def __init__( category="tip_carrier", model: Optional[str] = None): super().__init__(name, size_x, size_y, size_z, - sites,category=category, model=model) + sites, category=category, model=model) + + +class PlateCarrierSite(CarrierSite): + """ A single site within a plate carrier. """ + def __init__(self, name: str, size_x: float, size_y: float, size_z: float, + pedestal_size_z: float = None, # type: ignore + category="plate_carrier_site", model: Optional[str] = None): + super().__init__(name, size_x, size_y, size_z, category=category, model=model) + if pedestal_size_z is None: + raise ValueError("pedestal_size_z must be provided. See " + "https://docs.pylabrobot.org/plate_carriers.html#pedestal_size_z for more " + "information.") + + self.pedestal_size_z = pedestal_size_z + self.resource: Optional[Plate] = None # fix type + # TODO: add self.pedestal_2D_offset if necessary in the future + + def assign_child_resource(self, resource: Resource, location: Optional[Coordinate] = None, + reassign: bool = True): + if isinstance(resource, ResourceStack): + if not resource.direction == "z": + raise ValueError("ResourceStack assigned to PlateCarrierSite must have direction 'z'") + if not all(isinstance(c, Plate) for c in resource.children): + raise TypeError("If a ResourceStack is assigned to a PlateCarrierSite, the items " + \ + f"must be Plates, not {type(resource.children[-1])}") + elif not isinstance(resource, (Plate, PlateAdapter)): + raise TypeError("PlateCarrierSite can only store Plate, PlateAdapter or ResourceStack " + \ + f"resources, not {type(resource)}") + return super().assign_child_resource(resource, location, reassign) + + def _get_sinking_depth(self, resource: Resource) -> Coordinate: + def get_plate_sinking_depth(plate: Plate): + # Sanity check for equal well clearances / dz + well_dz_set = {round(well.location.z, 2) for well in plate.get_all_children() + if well.category == "well" and well.location is not None} + assert len(well_dz_set) == 1, "All wells must have the same z location" + well_dz = well_dz_set.pop() + # Plate "sinking" logic based on well dz to pedestal relationship + pedestal_size_z = abs(self.pedestal_size_z) + z_sinking_depth = min(pedestal_size_z, well_dz) + return z_sinking_depth + + z_sinking_depth = 0.0 + if isinstance(resource, Plate): + z_sinking_depth = get_plate_sinking_depth(resource) + elif isinstance(resource, ResourceStack) and len(resource.children) > 0: + first_child = resource.children[0] + if isinstance(first_child, Plate): + z_sinking_depth = get_plate_sinking_depth(first_child) + + # TODO #246 - _get_sinking_depth should not handle callbacks + resource.register_did_assign_resource_callback(self._update_resource_stack_location) + self.register_did_unassign_resource_callback(self._deregister_resource_stack_callback) + return -Coordinate(z=z_sinking_depth) + + def get_default_child_location(self, resource: Resource) -> Coordinate: + return super().get_default_child_location(resource) + self._get_sinking_depth(resource) + + def _update_resource_stack_location(self, resource: Resource): + """ Callback called when the lowest resource on a ResourceStack changes. Since the location of + the lowest resource on the stack wrt the ResourceStack is always 0,0,0, we need to update the + location of the ResourceStack itself to make sure we take into account sinking of the plate. + + Args: + resource: The Resource on the ResourceStack tht was assigned. + """ + resource_stack = resource.parent + assert isinstance(resource_stack, ResourceStack) + if resource_stack.children[0] == resource: + resource_stack.location = self.get_default_child_location(resource) + + def _deregister_resource_stack_callback(self, resource: Resource): + """ Callback called when a ResourceStack (or child) is unassigned from this PlateCarrierSite.""" + if isinstance(resource, ResourceStack): # the ResourceStack itself is unassigned + resource.deregister_did_assign_resource_callback(self._update_resource_stack_location) + + def serialize(self) -> dict: + return { **super().serialize(), "pedestal_size_z": self.pedestal_size_z, } class PlateCarrier(Carrier): - """ Base class for plate carriers. """ + r""" Base class for plate carriers. + Name prefix: 'PLT\_' + """ def __init__( self, name: str, size_x: float, size_y: float, size_z: float, - sites: Optional[List[CarrierSite]] = None, + sites: Optional[List[PlateCarrierSite]] = None, category="plate_carrier", model: Optional[str] = None): super().__init__(name, size_x, size_y, size_z, @@ -198,7 +285,9 @@ def __init__( class MFXCarrier(Carrier): - """ Base class for multiflex carriers (i.e. carriers with mixed-use and/or specialized sites). """ + r""" Base class for multiflex carriers (i.e. carriers with mixed-use and/or specialized sites). + Name prefix: 'MFX\_' + """ def __init__( self, name: str, @@ -212,9 +301,11 @@ def __init__( sites,category=category, model=model) -class ShakerCarrier(Carrier): - """ Base class for shaker carriers (i.e. 7-track carriers with mixed-use and/or specialized - sites). """ + +class TubeCarrier(Carrier): + r""" Base class for tube/sample carriers. + Name prefix: 'SMP\_' + """ def __init__( self, name: str, @@ -222,14 +313,16 @@ def __init__( size_y: float, size_z: float, sites: Optional[List[CarrierSite]] = None, - category="shaker_carrier", + category="tube_carrier", model: Optional[str] = None): super().__init__(name, size_x, size_y, size_z, - sites,category=category, model=model) + sites, category=category, model=model) -class TubeCarrier(Carrier): - """ Base class for tube/sample carriers. """ +class TroughCarrier(Carrier): + r""" Base class for reagent/trough carriers. + Name prefix: 'RGT\_' + """ def __init__( self, name: str, @@ -237,34 +330,44 @@ def __init__( size_y: float, size_z: float, sites: Optional[List[CarrierSite]] = None, - category="tube_carrier", + category="trough_carrier", model: Optional[str] = None): super().__init__(name, size_x, size_y, size_z, sites,category=category, model=model) +T = TypeVar("T", bound=CarrierSite) + + def create_carrier_sites( + klass: Type[T], locations: List[Coordinate], site_size_x: List[Union[float, int]], - site_size_y: List[Union[float, int]]) -> List[CarrierSite]: + site_size_y: List[Union[float, int]], + **kwargs +) -> List[T]: """ Create a list of carrier sites with the given sizes. """ sites = [] for spot, (l, x, y) in enumerate(zip(locations, site_size_x, site_size_y)): - site = CarrierSite( + site = klass( name = f"carrier-site-{spot}", - # size_x=x, size_y=y, size_z=0, spot=spot) - size_x=x, size_y=y, size_z=0) + size_x=x, size_y=y, size_z=0, + **kwargs) site.location = l sites.append(site) return sites def create_homogeneous_carrier_sites( + klass: Type[T], locations: List[Coordinate], site_size_x: float, - site_size_y: float) -> List[CarrierSite]: + site_size_y: float, + **kwargs +) -> List[T]: """ Create a list of carrier sites with the same size. """ n = len(locations) - return create_carrier_sites(locations, [site_size_x] * n, [site_size_y] * n) + return create_carrier_sites(klass=klass, locations=locations, site_size_x=[site_size_x]*n, + site_size_y=[site_size_y]*n, **kwargs) diff --git a/pylabrobot/resources/carrier_tests.py b/pylabrobot/resources/carrier_tests.py index edb5c61011..eb0c92f0b8 100644 --- a/pylabrobot/resources/carrier_tests.py +++ b/pylabrobot/resources/carrier_tests.py @@ -1,27 +1,31 @@ -""" Tests for Carrier resource """ # pylint: disable=missing-class-docstring import unittest -from .carrier import Carrier, TipCarrier, create_homogeneous_carrier_sites +from .carrier import ( + Carrier, CarrierSite, PlateCarrier, PlateCarrierSite, TipCarrier,create_homogeneous_carrier_sites) from .coordinate import Coordinate from .deck import Deck from .errors import ResourceNotFoundError +from .plate import Plate from .resource import Resource +from .resource_stack import ResourceStack from .tip_rack import TipRack +from .utils import create_ordered_items_2d +from .well import Well class CarrierTests(unittest.TestCase): def setUp(self): # pylint: disable=invalid-name - self.A = TipRack(name="A", size_x=5, size_y=5, size_z=5, items=[]) - self.B = TipRack(name="B", size_x=5, size_y=5, size_z=5, items=[]) - self.alsoB = TipRack(name="B", size_x=100, size_y=100, size_z=100, items=[]) + self.A = TipRack(name="A", size_x=5, size_y=5, size_z=5, ordered_items={}) + self.B = TipRack(name="B", size_x=5, size_y=5, size_z=5, ordered_items={}) + self.alsoB = TipRack(name="B", size_x=100, size_y=100, size_z=100, ordered_items={}) self.tip_car = TipCarrier( "tip_car", size_x=135.0, size_y=497.0, size_z=13.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10, 20, 30), Coordinate(10, 50, 30), Coordinate(10, 80, 30), @@ -35,7 +39,8 @@ def test_assign_in_order(self): carrier = Carrier( name="carrier", size_x=200, size_y=200, size_z=50, - sites=create_homogeneous_carrier_sites([Coordinate(5, 5, 5)], site_size_x=10, site_size_y=10) + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[Coordinate(5, 5, 5)], + site_size_x=10, site_size_y=10) ) plate = Resource("plate", size_x=10, size_y=10, size_z=10) carrier.assign_resource_to_site(plate, spot=0) @@ -50,7 +55,8 @@ def test_assign_build_carrier_first(self): carrier = Carrier( name="carrier", size_x=200, size_y=200, size_z=50, - sites=create_homogeneous_carrier_sites([Coordinate(5, 5, 5)], site_size_x=10, site_size_y=10) + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[Coordinate(5, 5, 5)], + site_size_x=10, site_size_y=10) ) plate = Resource("plate", size_x=10, size_y=10, size_z=10) carrier.assign_resource_to_site(plate, spot=0) @@ -66,7 +72,8 @@ def test_unassign_child(self): carrier = Carrier( name="carrier", size_x=200, size_y=200, size_z=50, - sites=create_homogeneous_carrier_sites([Coordinate(5, 5, 5)], site_size_x=10, site_size_y=10) + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[Coordinate(5, 5, 5)], + site_size_x=10, site_size_y=10) ) plate = Resource("plate", size_x=10, size_y=10, size_z=10) carrier.assign_resource_to_site(plate, spot=0) @@ -84,7 +91,8 @@ def test_assign_index_error(self): carrier = Carrier( name="carrier", size_x=200, size_y=200, size_z=50, - sites=create_homogeneous_carrier_sites([Coordinate(5, 5, 5)], site_size_x=10, site_size_y=10) + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[Coordinate(5, 5, 5)], + site_size_x=10, site_size_y=10) ) plate = Resource("plate", size_x=10, size_y=10, size_z=10) with self.assertRaises(IndexError): @@ -94,7 +102,8 @@ def test_absolute_location(self): carrier = Carrier( name="carrier", size_x=200, size_y=200, size_z=50, - sites=create_homogeneous_carrier_sites([Coordinate(5, 5, 5)], site_size_x=10, site_size_y=10) + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[Coordinate(5, 5, 5)], + site_size_x=10, site_size_y=10) ) carrier.location = Coordinate(10, 10, 10) plate = Resource("plate", size_x=10, size_y=10, size_z=10) @@ -164,6 +173,10 @@ def test_serialization(self): "size_y": 497.0, "size_z": 13.0, "location": None, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0 + }, "category": "tip_carrier", "model": None, "parent_name": None, @@ -180,6 +193,10 @@ def test_serialization(self): "y": 20, "z": 30 }, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0 + }, "category": "carrier_site", "children": [], "parent_name": "tip_car", @@ -197,6 +214,10 @@ def test_serialization(self): "y": 50, "z": 30 }, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0 + }, "category": "carrier_site", "children": [], "parent_name": "tip_car", @@ -214,6 +235,10 @@ def test_serialization(self): "y": 80, "z": 30 }, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0 + }, "category": "carrier_site", "children": [], "parent_name": "tip_car", @@ -231,6 +256,10 @@ def test_serialization(self): "y": 130, "z": 30 }, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0 + }, "category": "carrier_site", "children": [], "parent_name": "tip_car", @@ -248,6 +277,10 @@ def test_serialization(self): "y": 160, "z": 30 }, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0 + }, "category": "carrier_site", "children": [], "parent_name": "tip_car", @@ -265,3 +298,58 @@ def test_deserialization(self): sites=[] ) self.assertEqual(tip_car, TipCarrier.deserialize(tip_car.serialize())) + + def test_assign_resource_stack(self): + plate1 = Plate( + name="plate1", size_x=10, size_y=10, size_z=10, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=1, + num_items_y=1, + dx=0, dy=0, dz=5, + item_dx=10, item_dy=10, + size_x=1, size_y=1, size_z=1 + ) + ) + plate2 = Plate( + name="plate2", size_x=10, size_y=10, size_z=10, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=1, + num_items_y=1, + dx=0, dy=0, dz=6, + item_dx=10, item_dy=10, + size_x=1, size_y=1, size_z=1 + ) + ) + carrier = PlateCarrier( + name="carrier", + size_x=200, size_y=200, size_z=50, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[Coordinate(5,5,5)], + site_size_x=10, site_size_y=10, pedestal_size_z=10) + ) + resource_stack = ResourceStack( + name="resource_stack", + direction="z", + resources=[plate2, plate1] + ) + carrier[0] = resource_stack + self.assertEqual(resource_stack.location, Coordinate(0, 0, -5)) + self.assertEqual(plate1.location, Coordinate(0, 0, 0)) + self.assertEqual(plate2.location, Coordinate(0, 0, 10)) + + # change the resource stack so that plate2 is on the bottom + plate2.unassign() + plate1.unassign() + resource_stack.assign_child_resource(plate2) + self.assertEqual(resource_stack.location, Coordinate(0, 0, -6)) + self.assertEqual(plate2.location, Coordinate(0, 0, 0)) + + # pylint: disable=protected-access + pcs = carrier[0] + assert isinstance(pcs, PlateCarrierSite) + self.assertIn(pcs._update_resource_stack_location, + resource_stack._did_assign_resource_callbacks) + resource_stack.unassign() + self.assertNotIn(pcs._update_resource_stack_location, + resource_stack._did_assign_resource_callbacks) diff --git a/pylabrobot/resources/celltreat/plates.py b/pylabrobot/resources/celltreat/plates.py index 429a368a59..bfd3b8f2d4 100644 --- a/pylabrobot/resources/celltreat/plates.py +++ b/pylabrobot/resources/celltreat/plates.py @@ -1,13 +1,14 @@ from typing import Optional from pylabrobot.resources.height_volume_functions import ( - compute_height_from_volume_conical_frustum, compute_volume_from_height_conical_frustum) + compute_height_from_volume_conical_frustum, compute_height_from_volume_cylinder, + compute_volume_from_height_conical_frustum, compute_volume_from_height_cylinder) from pylabrobot.resources.plate import Lid, Plate -from pylabrobot.resources.utils import create_equally_spaced_2d -from pylabrobot.resources.well import Well, WellBottomType +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import CrossSectionType, Well, WellBottomType -def CellTreat_96_DWP_350ul_Ub(name: str, lid: Optional[Lid] = None) -> Plate: +def CellTreat_96_wellplate_350ul_Ub(name: str, lid: Optional[Lid] = None) -> Plate: """ CellTreat cat. no.: 229591 - Material: Polystyrene @@ -29,8 +30,8 @@ def CellTreat_96_DWP_350ul_Ub(name: str, lid: Optional[Lid] = None) -> Plate: size_y=85.11, size_z=14.30, # without lid lid=lid, - model=CellTreat_96_DWP_350ul_Ub.__name__, - items=create_equally_spaced_2d( + model=CellTreat_96_wellplate_350ul_Ub.__name__, + ordered_items=create_ordered_items_2d( Well, num_items_x=12, num_items_y=8, @@ -44,7 +45,7 @@ def CellTreat_96_DWP_350ul_Ub(name: str, lid: Optional[Lid] = None) -> Plate: ) -def CellTreat_96_DWP_350ul_Ub_Lid(name: str) -> Lid: +def CellTreat_96_wellplate_350ul_Ub_Lid(name: str) -> Lid: """ CellTreat cat. no.: 229591 - Material: Polystyrene @@ -56,11 +57,11 @@ def CellTreat_96_DWP_350ul_Ub_Lid(name: str) -> Lid: size_y=85.471, size_z=10.71, # measured nesting_z_height=8.30, # measured as height of plate "plateau" - model=CellTreat_96_DWP_350ul_Ub_Lid.__name__, + model=CellTreat_96_wellplate_350ul_Ub_Lid.__name__, ) -def CellTreat_6_DWP_16300ul_Fb(name: str, lid: Optional[Lid] = None) -> Plate: +def CellTreat_6_wellplate_16300ul_Fb(name: str, lid: Optional[Lid] = None) -> Plate: """ CellTreat cat. no.: 229105 - Material: Polystyrene @@ -89,8 +90,8 @@ def CellTreat_6_DWP_16300ul_Fb(name: str, lid: Optional[Lid] = None) -> Plate: size_y=85.38, # from plate specs/drawing size_z=20.2, # from plate specs/drawing lid=lid, - model=CellTreat_6_DWP_16300ul_Fb.__name__, - items=create_equally_spaced_2d( + model=CellTreat_6_wellplate_16300ul_Fb.__name__, + ordered_items=create_ordered_items_2d( Well, num_items_x=3, num_items_y=2, @@ -104,12 +105,57 @@ def CellTreat_6_DWP_16300ul_Fb(name: str, lid: Optional[Lid] = None) -> Plate: ) -def CellTreat_6_DWP_16300ul_Fb_Lid(name: str) -> Lid: +def CellTreat_6_wellplate_16300ul_Fb_Lid(name: str) -> Lid: return Lid( name=name, size_x=127.0, # from plate specs/drawing size_y=84.8, # from plate specs/drawing size_z=10.20, # measured nesting_z_height=9.0, # measured as difference between 2-stack and single - model=CellTreat_6_DWP_16300ul_Fb_Lid.__name__, + model=CellTreat_6_wellplate_16300ul_Fb_Lid.__name__, + ) + + +def CellTreat_96_wellplate_U(name: str, lid: Optional[Lid] = None) -> Plate: + """ + CellTreat cat. no.: 229590 + - Material: Polystyrene + - Tissue culture treated: No + """ + WELL_RADIUS = 3.175 + + well_kwargs = { + "size_x": 6.35, + "size_y": 6.35, + "size_z": 10.04, + "bottom_type": WellBottomType.U, + "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_cylinder( + liquid_height, WELL_RADIUS + ), + "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_cylinder( + liquid_volume, WELL_RADIUS + ), + "material_z_thickness": 1.55, + "cross_section_type": CrossSectionType.CIRCLE, + "max_volume": 300, + } + + return Plate( + name=name, + size_x=127.76, + size_y=85.11, + size_z=14.30, # without lid + lid=lid, + model=CellTreat_96_wellplate_U.__name__, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.7, # measured + dy=8.75, # measured + dz=2.6, # calibrated manually + item_dx=9, + item_dy=9, + **well_kwargs, + ), ) diff --git a/pylabrobot/resources/container.py b/pylabrobot/resources/container.py index 76e9a6fb80..6d40793cf5 100644 --- a/pylabrobot/resources/container.py +++ b/pylabrobot/resources/container.py @@ -1,11 +1,13 @@ -from abc import ABCMeta -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional from .resource import Resource +from .coordinate import Coordinate from .volume_tracker import VolumeTracker +from pylabrobot.serializer import serialize -class Container(Resource, metaclass=ABCMeta): + +class Container(Resource): """ A container is an abstract base class for a resource that can hold liquid. """ def __init__( @@ -14,25 +16,44 @@ def __init__( size_x: float, size_y: float, size_z: float, + material_z_thickness: Optional[float] = None, max_volume: Optional[float] = None, category: Optional[str] = None, - model: Optional[str] = None + model: Optional[str] = None, + compute_volume_from_height: Optional[Callable[[float], float]] = None, + compute_height_from_volume: Optional[Callable[[float], float]] = None, ): """ Create a new container. Args: + material_z_thickness: Container cavity base to the (outer) base of the container object. If + `None`, certain operations may not be supported. max_volume: Maximum volume of the container. If `None`, will be inferred from resource size. """ super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, category=category, model=model) + self._material_z_thickness = material_z_thickness self.max_volume = max_volume or (size_x * size_y * size_z) self.tracker = VolumeTracker(max_volume=self.max_volume) + self._compute_volume_from_height = compute_volume_from_height + self._compute_height_from_volume = compute_height_from_volume + + @property + def material_z_thickness(self) -> float: + if self._material_z_thickness is None: + raise NotImplementedError( + f"The current operation is not supported for resource named '{self.name}' of type " + f"'{self.__class__.__name__}' because material_z_thickness is not defined.") + return self._material_z_thickness def serialize(self) -> dict: return { **super().serialize(), - "max_volume": self.max_volume + "max_volume": self.max_volume, + "material_z_thickness": self._material_z_thickness, + "compute_volume_from_height": serialize(self._compute_volume_from_height), + "compute_height_from_volume": serialize(self._compute_height_from_volume), } def serialize_state(self) -> Dict[str, Any]: @@ -40,3 +61,50 @@ def serialize_state(self) -> Dict[str, Any]: def load_state(self, state: Dict[str, Any]): self.tracker.load_state(state) + + def compute_volume_from_height(self, height: float) -> float: + """ Compute the volume of liquid in a container from the height of the liquid relative to the + bottom of the container. """ + + if self._compute_volume_from_height is None: + raise NotImplementedError(f"compute_volume_from_height not implemented for {self.name}.") + + return self._compute_volume_from_height(height) + + def compute_height_from_volume(self, liquid_volume: float) -> float: + """ Compute the height of liquid in a container relative to the container's bottom + from the volume of the liquid. """ + + if self._compute_height_from_volume is None: + raise NotImplementedError(f"compute_height_from_volume not implemented for {self.name}.") + + return self._compute_height_from_volume(liquid_volume) + + def get_anchor(self, x: str, y: str, z: str) -> Coordinate: + """ Get a relative location within the container. (Update to Resource superclass to + include cavity_bottom) + + Args: + x: `"l"`/`"left"`, `"c"`/`"center"`, or `"r"`/`"right"` + y: `"b"`/`"back"`, `"c"`/`"center"`, or `"f"`/`"front"` + z: `"t"`/`"top"`, `"c"`/`"center"`, `"b"`/`"bottom"`, or `"cb"`/`"cavity_bottom"` + + Returns: + A relative location within the container, the anchor point wrt the left front bottom corner. + """ + + if z.lower() in {"cavity_bottom"}: + # Reuse superclass Resource method but update z location based on + # Container's additional information + coordinate = super().get_anchor(x, y, z="bottom") + x_, y_ = coordinate.x, coordinate.y + + if self._material_z_thickness is None: + raise ValueError("Cavity bottom only implemented for containers with a defined" + \ + f" material_z_thickness; you used {self.category}") + z_ = self._material_z_thickness + + return Coordinate(x_, y_, z_) + else: + return super().get_anchor(x, y, z) + diff --git a/pylabrobot/resources/coordinate.py b/pylabrobot/resources/coordinate.py index 0f1b19eedf..c8262a142c 100644 --- a/pylabrobot/resources/coordinate.py +++ b/pylabrobot/resources/coordinate.py @@ -18,8 +18,8 @@ def __post_init__(self): self.y = round(self.y, 4) self.z = round(self.z, 4) - @classmethod - def zero(cls) -> Coordinate: + @staticmethod + def zero() -> Coordinate: return Coordinate(0, 0, 0) def __add__(self, other) -> Coordinate: @@ -43,3 +43,6 @@ def __str__(self) -> str: def __neg__(self) -> Coordinate: return Coordinate(-self.x, -self.y, -self.z) + + def vector(self) -> list[float]: + return [self.x, self.y, self.z] diff --git a/pylabrobot/resources/corning_axygen/plates.py b/pylabrobot/resources/corning_axygen/plates.py index dae5c5786e..281f969444 100644 --- a/pylabrobot/resources/corning_axygen/plates.py +++ b/pylabrobot/resources/corning_axygen/plates.py @@ -2,11 +2,11 @@ # pylint: disable=invalid-name +from pylabrobot.resources.plate import Lid, Plate from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType -from pylabrobot.resources.itemized_resource import create_equally_spaced -from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d -from pylabrobot.resources.volume_functions import calculate_liquid_volume_container_2segments_square_vbottom +from pylabrobot.resources.height_volume_functions import calculate_liquid_volume_container_2segments_square_vbottom def _compute_volume_from_height_Axy_24_DW_10ML(h: float): @@ -20,17 +20,29 @@ def _compute_volume_from_height_Axy_24_DW_10ML(h: float): liquid_height=h) +def Axy_24_DW_10ML_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.76, + # size_y=86.0, + # size_z=5, + # nesting_z_height=None, # measure overlap between lid and plate + # model="Gre_1536_Sq_Lid", + # ) + + #: Axy_24_DW_10ML def Axy_24_DW_10ML(name: str, with_lid: bool = False) -> Plate: return Plate( name=name, - size_x=127.0, - size_y=86.0, + size_x=127.76, + size_y=85.48, size_z=44.24, - with_lid=with_lid, + lid=Axy_24_DW_10ML_Lid(name + "_lid") if with_lid else None, model="Axy_24_DW_10ML", - lid_height=5, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=6, num_items_y=4, dx=9.8, @@ -55,4 +67,4 @@ def Axy_24_DW_10ML_L(name: str, with_lid: bool = False) -> Plate: #: Axy_24_DW_10ML_P def Axy_24_DW_10ML_P(name: str, with_lid: bool = False) -> Plate: - return Axy_24_DW_10ML(name=name, with_lid=with_lid).rotated(90) + return Axy_24_DW_10ML(name=name, with_lid=with_lid).rotated(z=90) diff --git a/pylabrobot/resources/corning_costar/plates.py b/pylabrobot/resources/corning_costar/plates.py index 32d8ad38c8..6d3f1174a0 100644 --- a/pylabrobot/resources/corning_costar/plates.py +++ b/pylabrobot/resources/corning_costar/plates.py @@ -1,11 +1,22 @@ """ Corning Costar plates """ # pylint: disable=invalid-name +# pylint: disable=unreachable -from pylabrobot.resources.plate import Plate +from typing import Optional +from pylabrobot.resources.errors import ResourceDefinitionIncompleteError +from pylabrobot.resources.plate import Lid, Plate from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType -from pylabrobot.resources.itemized_resource import create_equally_spaced +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.height_volume_functions import ( + calculate_liquid_height_container_1segment_round_fbottom, + calculate_liquid_height_in_container_2segments_square_vbottom, + calculate_liquid_volume_container_1segment_round_fbottom, + calculate_liquid_volume_container_2segments_square_vbottom, + compute_height_from_volume_conical_frustum, + compute_volume_from_height_conical_frustum +) def _compute_volume_from_height_Cos_1536_10ul(h: float) -> float: @@ -17,17 +28,32 @@ def _compute_volume_from_height_Cos_1536_10ul(h: float) -> float: return volume +def Cos_1536_10ul_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_1536_10ul_Lid", + # ) + + def Cos_1536_10ul(name: str, with_lid: bool = False) -> Plate: """ Cos_1536_10ul """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_1536_10ul") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=10.25, - with_lid=with_lid, + lid=Cos_1536_10ul_Lid(name=name + "_lid") if with_lid else None, model="Cos_1536_10ul", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=48, num_items_y=32, dx=9.675, @@ -50,7 +76,7 @@ def Cos_1536_10ul_L(name: str, with_lid: bool = False) -> Plate: def Cos_1536_10ul_P(name: str, with_lid: bool = False) -> Plate: """ Cos_1536_10ul """ - return Cos_1536_10ul(name=name, with_lid=with_lid).rotated(90) + return Cos_1536_10ul(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_384_DW(h: float) -> float: volume = min(h, 1.0)*min(h, 1.0)*(4.3982 - 1.0472*min(h, 1.0)) @@ -61,17 +87,32 @@ def _compute_volume_from_height_Cos_384_DW(h: float) -> float: return volume +def Cos_384_DW_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_384_DW_Lid", + # ) + + def Cos_384_DW(name: str, with_lid: bool = False) -> Plate: """ Cos_384_DW """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_384_DW") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=28.0, - with_lid=with_lid, + lid=Cos_384_DW_Lid(name=name + "_lid") if with_lid else None, model="Cos_384_DW", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=16, dx=9.95, @@ -94,7 +135,7 @@ def Cos_384_DW_L(name: str, with_lid: bool = False) -> Plate: def Cos_384_DW_P(name: str, with_lid: bool = False) -> Plate: """ Cos_384_DW """ - return Cos_384_DW(name=name, with_lid=with_lid).rotated(90) + return Cos_384_DW(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_384_PCR(h: float) -> float: volume = min(h, 9.5)*2.8510 @@ -103,17 +144,32 @@ def _compute_volume_from_height_Cos_384_PCR(h: float) -> float: return volume +def Cos_384_PCR_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_384_PCR_Lid", + # ) + + def Cos_384_PCR(name: str, with_lid: bool = False) -> Plate: """ Cos_384_PCR """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_384_PCR") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=16.0, - with_lid=with_lid, + lid=Cos_384_PCR_Lid(name=name + "_lid") if with_lid else None, model="Cos_384_PCR", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=16, dx=10.1, @@ -136,7 +192,7 @@ def Cos_384_PCR_L(name: str, with_lid: bool = False) -> Plate: def Cos_384_PCR_P(name: str, with_lid: bool = False) -> Plate: """ Cos_384_PCR """ - return Cos_384_PCR(name=name, with_lid=with_lid).rotated(90) + return Cos_384_PCR(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_384_Sq(h: float) -> float: volume = min(h, 11.56)*12.2500 @@ -145,17 +201,32 @@ def _compute_volume_from_height_Cos_384_Sq(h: float) -> float: return volume +def Cos_384_Sq_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_384_Sq_Lid", + # ) + + def Cos_384_Sq(name: str, with_lid: bool = False) -> Plate: """ Cos_384_Sq """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_384_Sq") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=14.24, - with_lid=with_lid, + lid=Cos_384_Sq_Lid(name=name + "_lid") if with_lid else None, model="Cos_384_Sq", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=16, dx=10.0, @@ -178,7 +249,7 @@ def Cos_384_Sq_L(name: str, with_lid: bool = False) -> Plate: def Cos_384_Sq_P(name: str, with_lid: bool = False) -> Plate: """ Cos_384_Sq """ - return Cos_384_Sq(name=name, with_lid=with_lid).rotated(90) + return Cos_384_Sq(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_384_Sq_Rd(h: float) -> float: volume = min(h, 1.0)*min(h, 1.0)*(4.3982 - 1.0472*min(h, 1.0)) @@ -189,17 +260,32 @@ def _compute_volume_from_height_Cos_384_Sq_Rd(h: float) -> float: return volume +def Cos_384_Sq_Rd_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_384_Sq_Rd_Lid", + # ) + + def Cos_384_Sq_Rd(name: str, with_lid: bool = False) -> Plate: """ Cos_384_Sq_Rd """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_384_Sq_Rd") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=14.5, - with_lid=with_lid, + lid=Cos_384_Sq_Rd_Lid(name=name + "_lid") if with_lid else None, model="Cos_384_Sq_Rd", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=16, dx=9.95, @@ -222,7 +308,7 @@ def Cos_384_Sq_Rd_L(name: str, with_lid: bool = False) -> Plate: def Cos_384_Sq_Rd_P(name: str, with_lid: bool = False) -> Plate: """ Cos_384_Sq_Rd """ - return Cos_384_Sq_Rd(name=name, with_lid=with_lid).rotated(90) + return Cos_384_Sq_Rd(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_DW_1mL(h: float) -> float: volume = min(h, 2.5)*min(h, 2.5)*(10.2102 - 1.0472*min(h, 2.5)) @@ -233,17 +319,32 @@ def _compute_volume_from_height_Cos_96_DW_1mL(h: float) -> float: return volume +def Cos_96_DW_1mL_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_DW_1mL_Lid", + # ) + + def Cos_96_DW_1mL(name: str, with_lid: bool = False) -> Plate: """ Cos_96_DW_1mL """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_DW_1mL") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=42.0, - with_lid=with_lid, + lid=Cos_96_DW_1mL_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_DW_1mL", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.75, @@ -266,7 +367,7 @@ def Cos_96_DW_1mL_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_DW_1mL_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_DW_1mL """ - return Cos_96_DW_1mL(name=name, with_lid=with_lid).rotated(90) + return Cos_96_DW_1mL(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_DW_2mL(h: float) -> float: volume = min(h, 4.0)*min(h, 4.0)*(12.5664 - 1.0472*min(h, 4.0)) @@ -277,17 +378,32 @@ def _compute_volume_from_height_Cos_96_DW_2mL(h: float) -> float: return volume +def Cos_96_DW_2mL_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_DW_2mL_Lid", + # ) + + def Cos_96_DW_2mL(name: str, with_lid: bool = False) -> Plate: """ Cos_96_DW_2mL """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_DW_2mL") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=43.5, - with_lid=with_lid, + lid=Cos_96_DW_2mL_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_DW_2mL", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.0, @@ -310,7 +426,7 @@ def Cos_96_DW_2mL_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_DW_2mL_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_DW_2mL """ - return Cos_96_DW_2mL(name=name, with_lid=with_lid).rotated(90) + return Cos_96_DW_2mL(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_DW_500ul(h: float) -> float: volume = min(h, 1.5)*10.7233 @@ -321,17 +437,32 @@ def _compute_volume_from_height_Cos_96_DW_500ul(h: float) -> float: return volume +def Cos_96_DW_500ul_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_DW_500ul_Lid", + # ) + + def Cos_96_DW_500ul(name: str, with_lid: bool = False) -> Plate: """ Cos_96_DW_500ul """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_DW_500ul") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=27.5, - with_lid=with_lid, + lid=Cos_96_DW_500ul_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_DW_500ul", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.55, @@ -354,7 +485,7 @@ def Cos_96_DW_500ul_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_DW_500ul_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_DW_500ul """ - return Cos_96_DW_500ul(name=name, with_lid=with_lid).rotated(90) + return Cos_96_DW_500ul(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_EZWash(h: float) -> float: volume = min(h, 11.3)*37.3928 @@ -363,32 +494,21 @@ def _compute_volume_from_height_Cos_96_EZWash(h: float) -> float: return volume +def Cos_96_EZWash_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_EZWash_Lid", + # ) + + def Cos_96_EZWash(name: str, with_lid: bool = False) -> Plate: - """ Cos_96_EZWash """ - return Plate( - name=name, - size_x=127.0, - size_y=86.0, - size_z=14.5, - with_lid=with_lid, - model="Cos_96_EZWash", - lid_height=10, - items=create_equally_spaced(Well, - num_items_x=12, - num_items_y=8, - dx=10.55, - dy=8.05, - dz=1.0, - item_dx=9.0, - item_dy=9.0, - size_x=6.9, - size_y=6.9, - size_z=11.3, - bottom_type=WellBottomType.FLAT, - cross_section_type=CrossSectionType.CIRCLE, - compute_volume_from_height=_compute_volume_from_height_Cos_96_EZWash, - ), - ) + raise ValueError("Deprecated. You probably want to use Cor_96_wellplate_360ul_Fb instead.") def Cos_96_EZWash_L(name: str, with_lid: bool = False) -> Plate: """ Cos_96_EZWash """ @@ -396,7 +516,8 @@ def Cos_96_EZWash_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_EZWash_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_EZWash """ - return Cos_96_EZWash(name=name, with_lid=with_lid).rotated(90) + return Cos_96_EZWash(name=name, with_lid=with_lid).rotated(z=90) + def _compute_volume_from_height_Cos_96_FL(h: float) -> float: volume = min(h, 10.67)*34.2808 @@ -405,17 +526,32 @@ def _compute_volume_from_height_Cos_96_FL(h: float) -> float: return volume +def Cos_96_FL_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_FL_Lid", + # ) + + def Cos_96_FL(name: str, with_lid: bool = False) -> Plate: """ Cos_96_FL """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_FL") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=14.24, - with_lid=with_lid, + lid=Cos_96_FL_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_FL", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.57, @@ -439,17 +575,32 @@ def _compute_volume_from_height_Cos_96_Filter(h: float) -> float: return volume +def Cos_96_Filter_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_Filter_Lid", + # ) + + def Cos_96_Filter(name: str, with_lid: bool = False) -> Plate: """ Cos_96_Filter """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_Filter") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=14.5, - with_lid=with_lid, + lid=Cos_96_Filter_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_Filter", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.55, @@ -472,8 +623,15 @@ def Cos_96_Filter_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_Filter_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_Filter """ - return Cos_96_Filter(name=name, with_lid=with_lid).rotated(90) + return Cos_96_Filter(name=name, with_lid=with_lid).rotated(z=90) +def Cos_96_Fl_L(name: str, with_lid: bool = False) -> Plate: + """ Cos_96_Fl """ + return Cos_96_FL(name=name, with_lid=with_lid) + +def Cos_96_Fl_P(name: str, with_lid: bool = False) -> Plate: + """ Cos_96_Fl """ + return Cos_96_FL(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_HalfArea(h: float) -> float: volume = min(h, 10.7)*17.7369 @@ -482,17 +640,32 @@ def _compute_volume_from_height_Cos_96_HalfArea(h: float) -> float: return volume +def Cos_96_HalfArea_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_HalfArea_Lid", + # ) + + def Cos_96_HalfArea(name: str, with_lid: bool = False) -> Plate: """ Cos_96_HalfArea """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_HalfArea") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=14.5, - with_lid=with_lid, + lid=Cos_96_HalfArea_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_HalfArea", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=11.5, @@ -515,7 +688,7 @@ def Cos_96_HalfArea_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_HalfArea_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_HalfArea """ - return Cos_96_HalfArea(name=name, with_lid=with_lid).rotated(90) + return Cos_96_HalfArea(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_PCR(h: float) -> float: volume = min(h, 11.5)*6.5450 @@ -526,17 +699,32 @@ def _compute_volume_from_height_Cos_96_PCR(h: float) -> float: return volume +def Cos_96_PCR_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_PCR_Lid", + # ) + + def Cos_96_PCR(name: str, with_lid: bool = False) -> Plate: """ Cos_96_PCR """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_PCR") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=22.5, - with_lid=with_lid, + lid=Cos_96_PCR_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_PCR", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=11.0, @@ -559,7 +747,7 @@ def Cos_96_PCR_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_PCR_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_PCR """ - return Cos_96_PCR(name=name, with_lid=with_lid).rotated(90) + return Cos_96_PCR(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_ProtCryst(h: float) -> float: volume = min(h, 1.6)*7.5477 @@ -568,17 +756,32 @@ def _compute_volume_from_height_Cos_96_ProtCryst(h: float) -> float: return volume +def Cos_96_ProtCryst_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_ProtCryst_Lid", + # ) + + def Cos_96_ProtCryst(name: str, with_lid: bool = False) -> Plate: """ Cos_96_ProtCryst """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_ProtCryst") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=20.0, - with_lid=with_lid, + lid=Cos_96_ProtCryst_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_ProtCryst", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=8, dx=10.15, @@ -601,7 +804,7 @@ def Cos_96_ProtCryst_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_ProtCryst_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_ProtCryst """ - return Cos_96_ProtCryst(name=name, with_lid=with_lid).rotated(90) + return Cos_96_ProtCryst(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_Rd(h: float) -> float: volume = min(h, 0.6)*min(h, 0.6)*(10.0531 - 1.0472*min(h, 0.6)) @@ -612,17 +815,32 @@ def _compute_volume_from_height_Cos_96_Rd(h: float) -> float: return volume +def Cos_96_Rd_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_Rd_Lid", + # ) + + def Cos_96_Rd(name: str, with_lid: bool = False) -> Plate: """ Cos_96_Rd """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_Rd") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=14.5, - with_lid=with_lid, + lid=Cos_96_Rd_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_Rd", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.55, @@ -645,7 +863,7 @@ def Cos_96_Rd_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_Rd_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_Rd """ - return Cos_96_Rd(name=name, with_lid=with_lid).rotated(90) + return Cos_96_Rd(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_SpecOps(h: float) -> float: volume = min(h, 11.0)*34.7486 @@ -654,17 +872,32 @@ def _compute_volume_from_height_Cos_96_SpecOps(h: float) -> float: return volume +def Cos_96_SpecOps_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_SpecOps_Lid", + # ) + + def Cos_96_SpecOps(name: str, with_lid: bool = False) -> Plate: """ Cos_96_SpecOps """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_SpecOps") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=14.3, - with_lid=with_lid, + lid=Cos_96_SpecOps_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_SpecOps", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.55, @@ -687,7 +920,7 @@ def Cos_96_SpecOps_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_SpecOps_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_SpecOps """ - return Cos_96_SpecOps(name=name, with_lid=with_lid).rotated(90) + return Cos_96_SpecOps(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_UV(h: float) -> float: volume = min(h, 11.0)*34.7486 @@ -696,17 +929,32 @@ def _compute_volume_from_height_Cos_96_UV(h: float) -> float: return volume +def Cos_96_UV_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_UV_Lid", + # ) + + def Cos_96_UV(name: str, with_lid: bool = False) -> Plate: """ Cos_96_UV """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_UV") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=14.3, - with_lid=with_lid, + lid=Cos_96_UV_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_UV", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.55, @@ -729,7 +977,7 @@ def Cos_96_UV_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_UV_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_UV """ - return Cos_96_UV(name=name, with_lid=with_lid).rotated(90) + return Cos_96_UV(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Cos_96_Vb(h: float) -> float: volume = min(h, 1.4)*10.5564 @@ -740,17 +988,32 @@ def _compute_volume_from_height_Cos_96_Vb(h: float) -> float: return volume +def Cos_96_Vb_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_Vb_Lid", + # ) + + def Cos_96_Vb(name: str, with_lid: bool = False) -> Plate: """ Cos_96_Vb """ + + raise ResourceDefinitionIncompleteError(resource_name="Cos_96_Vb") + return Plate( name=name, size_x=127.0, size_y=86.0, size_z=14.24, - with_lid=with_lid, + lid=Cos_96_Vb_Lid(name=name + "_lid") if with_lid else None, model="Cos_96_Vb", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.55, @@ -773,4 +1036,278 @@ def Cos_96_Vb_L(name: str, with_lid: bool = False) -> Plate: def Cos_96_Vb_P(name: str, with_lid: bool = False) -> Plate: """ Cos_96_Vb """ - return Cos_96_Vb(name=name, with_lid=with_lid).rotated(90) + return Cos_96_Vb(name=name, with_lid=with_lid).rotated(z=90) + + +############ User-defined PLR Cos plates ############ + + +# # # # # # # # # # Cos_6_wellplate_16800ul_Fb # # # # # # # # # # + +def Cos_6_wellplate_16800ul_Fb_Lid(name: str) -> Lid: + return Lid( + name=name, + size_x=127.0, + size_y=86.0, + size_z=7.8, + nesting_z_height=6.7, # measure overlap between lid and plate + model="Cos_6_wellplate_16800ul_Fb_Lid", + ) + +def _compute_volume_from_height_Cos_6_wellplate_16800ul_Fb(h: float): + if h > 18.0: + raise ValueError(f"Height {h} is too large for Cos_6_wellplate_16800ul_Fb") + return calculate_liquid_volume_container_1segment_round_fbottom( + d=35.0, + h_cylinder=18.2, + liquid_height=h) + +def _compute_height_from_volume_Cos_6_wellplate_16800ul_Fb(liquid_volume: float): + if liquid_volume > 17_640: # 5% tolerance + raise ValueError(f"Volume {liquid_volume} is too large for Cos_6_wellplate_16800ul_Fb") + return calculate_liquid_height_container_1segment_round_fbottom( + d=35.0, + h_cylinder=18.2, + liquid_volume=liquid_volume) + +def Cos_6_wellplate_16800ul_Fb(name: str, with_lid: bool = True) -> Plate: + """Corning-Costar 6-well multi-well plate (MWP); product no.: 3516. + - Material: ? + - Cleanliness: 3516: sterilized by gamma irradiation + - Nonreversible lids with condensation rings to reduce contamination + - Treated for optimal cell attachment + - Cell growth area: 9.5 cm² (approx.) + - Total volume: 16.8 mL + """ + return Plate( + name=name, + size_x=127.0, + size_y=86.0, + size_z=20.0, + lid=Cos_6_wellplate_16800ul_Fb_Lid(name=name + "_lid") if with_lid else None, + model="Cos_6_wellplate_16800ul_Fb", + ordered_items=create_ordered_items_2d(Well, + num_items_x=3, + num_items_y=2, + dx=7.0, + dy=5.45, + dz=1.35, + item_dx=38.45, + item_dy=38.45, + size_x=35.0, + size_y=35.0, + size_z=17.5, + bottom_type=WellBottomType.FLAT, + material_z_thickness=1.4, + cross_section_type=CrossSectionType.CIRCLE, + compute_volume_from_height=_compute_volume_from_height_Cos_6_wellplate_16800ul_Fb, + compute_height_from_volume=_compute_height_from_volume_Cos_6_wellplate_16800ul_Fb, + ), + ) + +def Cos_6_wellplate_16800ul_Fb_L(name: str, with_lid: bool = True) -> Plate: + return Cos_6_wellplate_16800ul_Fb(name=name, with_lid=with_lid) + +def Cos_6_wellplate_16800ul_Fb_P(name: str, with_lid: bool = True) -> Plate: + return Cos_6_wellplate_16800ul_Fb(name=name, with_lid=with_lid).rotated(z=90) + + +# # # # # # # # # # Cos_96_wellplate_2mL_Vb # # # # # # # # # # + +def _compute_volume_from_height_Cos_96_wellplate_2mL_Vb(h: float) -> float: + if h > 44.1: # 5% tolerance + raise ValueError(f"Height {h} is too large for Cos_96_DWP_2mL_Vb") + return calculate_liquid_volume_container_2segments_square_vbottom( + x=7.8, + y=7.8, + h_pyramid=4.0, + h_cube=38.0, + liquid_height=h) + +def _compute_height_from_volume_Cos_96_wellplate_2mL_Vb(liquid_volume: float): + if liquid_volume > 2_100: # 5% tolerance + raise ValueError(f"Volume {liquid_volume} is too large for Cos_96_wellpate_2mL_Vb") + return round(calculate_liquid_height_in_container_2segments_square_vbottom( + x=7.8, + y=7.8, + h_pyramid=4.0, + h_cube=38.0, + liquid_volume=liquid_volume),3) + +def Cos_96_wellplate_2mL_Vb_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Cos_96_wellplate_2mL_Vb_Lid", + # ) + +def Cos_96_DWP_2mL_Vb(name: str, with_lid: bool = False) -> Plate: + raise NotImplementedError("This function is deprecated and will be removed in a future version." + " Use 'Cos_96_wellplate_2mL_Vb' instead.") + +def Cos_96_wellplate_2mL_Vb(name: str, with_lid: bool = False) -> Plate: + """ Corning 96 deep-well 2 mL PCR plate. Corning cat. no.: 3960 + - Material: Polypropylene + - Resistant to many common organic solvents (e.g., DMSO, ethanol, methanol) + - 3960: Sterile and DNase- and RNase-free + - Total volume: 2 mL + - Features uniform skirt heights for greater robotic gripping surface + """ + return Plate( + name=name, + size_x=127.0, + size_y=86.0, + size_z=43.5, + lid=Cos_96_wellplate_2mL_Vb_Lid(name=name + "_lid") if with_lid else None, + model="Cos_96_wellplate_2mL_Vb", + ordered_items=create_ordered_items_2d(Well, + num_items_x=12, + num_items_y=8, + dx=9.6, + dy=7.0, + dz=1.2, + item_dx=9.0, + item_dy=9.0, + size_x=8.0, + size_y=8.0, + size_z=42.0, + bottom_type=WellBottomType.V, + material_z_thickness=1.25, + cross_section_type=CrossSectionType.RECTANGLE, + compute_volume_from_height=_compute_volume_from_height_Cos_96_wellplate_2mL_Vb, + compute_height_from_volume=_compute_height_from_volume_Cos_96_wellplate_2mL_Vb + ), + ) + +def Cos_96_wellplate_2mL_Vb_L(name: str, with_lid: bool = False) -> Plate: + """ Cos_96_wellplate_2mL_Vb """ + return Cos_96_wellplate_2mL_Vb(name=name, with_lid=with_lid) + +def Cos_96_wellplate_2mL_Vb_P(name: str, with_lid: bool = False) -> Plate: + """ Cos_96_wellplate_2mL_Vb """ + return Cos_96_wellplate_2mL_Vb(name=name, with_lid=with_lid).rotated(z=90) + + +# # # # # # # # # # Cor_96_wellplate_360ul_Fb # # # # # # # # # # + +def Cor_96_wellplate_360ul_Fb_Lid(name: str) -> Lid: + return Lid( + name=name, + size_x=127.76, + size_y=85.48, + size_z=8.9, # measure the total z height + nesting_z_height=7.6, # measure overlap between lid and plate + model="Cor_96_wellplate_360ul_Fb_Lid", + ) + + +def Cor_96_wellplate_360ul_Fb(name: str, with_lid: bool = False) -> Plate: + """ Cor_96_wellplate_360ul_Fb + + Catalog number 3603 + + https://ecatalog.corning.com/life-sciences/b2b/NL/en/Microplates/Assay-Microplates/96-Well- + Microplates/Corning®-96-well-Black-Clear-and-White-Clear-Bottom-Polystyrene-Microplates/p/3603 + + Measurements found here: + https://www.corning.com/catalog/cls/documents/drawings/MicroplateDimensions96-384-1536.pdf + https://archive.vn/CnRgl + """ + + # This used to be Cos_96_EZWash in the Esvelt lab + # + # return Plate( + # name=name, + # size_x=127, + # size_y=86, + # size_z=14.5, + # lid=Cos_96_EZWash_Lid(name=name + "_lid") if with_lid else None, + # model="Cos_96_EZWash", + # ordered_items=create_ordered_items_2d(Well, + # num_items_x=12, + # num_items_y=8, + # dx=10.55, + # dy=8.05, + # dz=1.0, + # item_dx=9.0, + # item_dy=9.0, + # size_x=6.9, + # size_y=6.9, + # size_z=11.3, + # bottom_type=WellBottomType.FLAT, + # cross_section_type=CrossSectionType.CIRCLE, + # ), + # ) + + return Plate( + name=name, + size_x=127.76, + size_y=85.48, + size_z=14.2, + lid=Cor_96_wellplate_360ul_Fb_Lid(name=name + "_lid") if with_lid else None, + model="Cor_96_wellplate_360ul_Fb", + ordered_items=create_ordered_items_2d(Well, + num_items_x=12, + num_items_y=8, + dx=10.87, # 14.3-6.86/2 + dy=7.77, # 11.2-6.86/2 + dz=3.03, + item_dx=9.0, + item_dy=9.0, + size_x=6.86, # top + size_y=6.86, # top + size_z=10.67, + material_z_thickness=0.5, + bottom_type=WellBottomType.FLAT, + cross_section_type=CrossSectionType.CIRCLE, + max_volume=360, + ), + ) + +def Cor_6_wellplate_Fl(name: str, lid: Optional[Lid] = None) -> Plate: + """ + Corning cat. no.: 3471 + - Material: Polystyrene + - Tissue culture treated: No + """ + BOTTOM_INNER_WELL_RADIUS = 34.798 / 2 # from Corning Product Description + TOP_INNER_WELL_RADIUS = 35.433 / 2 # from Corning Product Description + + well_kwargs = { + "size_x": BOTTOM_INNER_WELL_RADIUS * 2, + "size_y": BOTTOM_INNER_WELL_RADIUS * 2, + "size_z": 17.399, # from Corning Product Description + "bottom_type": WellBottomType.FLAT, + "max_volume": 16.850, # calculated + "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_conical_frustum( + liquid_height, BOTTOM_INNER_WELL_RADIUS, TOP_INNER_WELL_RADIUS + ), + "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_conical_frustum( + liquid_volume, BOTTOM_INNER_WELL_RADIUS, TOP_INNER_WELL_RADIUS + ), + } + + return Plate( + name=name, + size_x=127.762, # from Corning Product Description + size_y=85.471, # from Corning Product Description + size_z=22.6314, # from Corning Product Description + lid=lid, + model=Cor_6_wellplate_Fl.__name__, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=3, + num_items_y=2, + dx=7.5, # measured + dy=6.3, # measured + dz=1.8, # calibrated manually by z-stepping down using a pipette. + item_dx=39, # measured + item_dy=39, # measured + **well_kwargs, + ), + ) diff --git a/pylabrobot/resources/deck.py b/pylabrobot/resources/deck.py index 6b1abc9d3a..45352ee8d7 100644 --- a/pylabrobot/resources/deck.py +++ b/pylabrobot/resources/deck.py @@ -95,18 +95,24 @@ def get_all_resources(self) -> List[Resource]: """ Returns a list of all resources in the deck. """ return list(self.resources.values()) - def clear(self): + def clear(self, include_trash: bool = False): """ Removes all resources from the deck. Examples: - Clearing all resources on a liquid handler deck: >>> lh.deck.clear() + + Clearing all resources on a liquid handler deck, including the trash area: + + >>> lh.deck.clear(include_trash=True) """ - all_resources = list(self.resources.values()) # can't change size during iteration - for resource in all_resources: + children_names = [child.name for child in self.children] + for resource_name in children_names: + resource = self.get_resource(resource_name) + if isinstance(resource, Trash) and not include_trash: + continue resource.unassign() def get_trash_area(self) -> Trash: @@ -117,7 +123,7 @@ def get_trash_area(self) -> Trash: def summary(self) -> str: """ Returns a summary of the deck layout. """ - summary_ = f"Deck: {self.get_size_x()} x {self.get_size_y()} mm\n\n" + summary_ = f"Deck: {self.get_absolute_size_x()} x {self.get_absolute_size_y()} mm\n\n" for resource in self.children: summary_ += f"{resource.name}: {resource}\n" return summary_ diff --git a/pylabrobot/resources/deck_tests.py b/pylabrobot/resources/deck_tests.py index e6c5c998ea..fe7e55660d 100644 --- a/pylabrobot/resources/deck_tests.py +++ b/pylabrobot/resources/deck_tests.py @@ -3,17 +3,19 @@ import unittest from pylabrobot.resources import ( + CarrierSite, Coordinate, Deck, Plate, PlateCarrier, + PlateCarrierSite, Resource, TipCarrier, TipRack, TipSpot, Well, ResourceNotFoundError, - create_equally_spaced, + create_ordered_items_2d, standard_volume_tip_with_filter, create_homogeneous_carrier_sites ) @@ -37,8 +39,12 @@ def test_assign_resource_twice(self): def test_clear(self): deck = Deck() - resource = Resource(name="resource", size_x=1, size_y=1, size_z=1) - deck.assign_child_resource(resource, location=Coordinate.zero()) + r1 = Resource(name="r1", size_x=1, size_y=1, size_z=1) + r2 = Resource(name="r2", size_x=1, size_y=1, size_z=1) + r3 = Resource(name="r3", size_x=1, size_y=1, size_z=1) + deck.assign_child_resource(r1, location=Coordinate.zero()) + deck.assign_child_resource(r2, location=Coordinate(x=2)) + deck.assign_child_resource(r3, location=Coordinate(x=4)) deck.clear() with self.assertRaises(ResourceNotFoundError): deck.get_resource("resource") @@ -49,20 +55,21 @@ def test_json_serialization_standard(self): # test with custom classes custom_1 = Deck() - tc = TipCarrier("tc", 200, 200, 200, sites=create_homogeneous_carrier_sites([ - Coordinate(10, 20, 30)], site_size_x=10, site_size_y=10)) + tc = TipCarrier("tc", 200, 200, 200, sites=create_homogeneous_carrier_sites(klass=CarrierSite, + locations=[Coordinate(10, 20, 30)], site_size_x=10, site_size_y=10)) tc[0] = TipRack("tips", 10, 20, 30, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=1, num_items_y=1, dx=-1, dy=-1, dz=-1, item_dx=1, item_dy=1, size_x=1, size_y=1, make_tip=standard_volume_tip_with_filter)) - pc = PlateCarrier("pc", 100, 100, 100, sites=create_homogeneous_carrier_sites([ - Coordinate(10, 20, 30)], site_size_x=10, site_size_y=10)) + pc = PlateCarrier("pc", 100, 100, 100, sites=create_homogeneous_carrier_sites( + klass=PlateCarrierSite, locations=[Coordinate(10, 20, 30)], site_size_x=10, site_size_y=10, + pedestal_size_z=0)) pc[0] = Plate("plate", 10, 20, 30, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=1, num_items_y=1, dx=-1, dy=-1, dz=-1, item_dx=1, item_dy=1, diff --git a/pylabrobot/resources/eppendorf/engineering_diagrams/Eppendorf-twintec-PCR-Plate-96_unskirted_divisible_250-ul.pdf b/pylabrobot/resources/eppendorf/engineering_diagrams/Eppendorf-twintec-PCR-Plate-96_unskirted_divisible_250-ul.pdf new file mode 100644 index 0000000000..73237cebfa Binary files /dev/null and b/pylabrobot/resources/eppendorf/engineering_diagrams/Eppendorf-twintec-PCR-Plate-96_unskirted_divisible_250-ul.pdf differ diff --git a/pylabrobot/resources/errors.py b/pylabrobot/resources/errors.py index 38225b8ddb..7b551af788 100644 --- a/pylabrobot/resources/errors.py +++ b/pylabrobot/resources/errors.py @@ -16,3 +16,29 @@ class HasTipError(Exception): class NoTipError(Exception): """ Raised when a tip was expected but none was found. """ + + +class CrossContaminationError(Exception): + """ Raised when attempting to aspirate from a well with a tip that has touched a different liquid. + """ + + +class ResourceDefinitionIncompleteError(Exception): + """ Raised when trying to access a resource that has not been defined or is not complete. + + We have some "phantom" resources that have a name and creator function, but are missing some + information, or they don't have enough metadata to uniquely identify them. This means they are + effectively useless. These resources often originate from a database import (like venus) that is + incomplete. + + This error is raised when you try to create a resource like that. Please create a PR to list the + resource catalog number (or equivalent), or measure and contribute the missing information. Please + create an issue if you need help with this. + + Tracking the general problem in https://github.com/PyLabRobot/pylabrobot/issues/170. + """ + + def __init__(self, resource_name: str): + super().__init__(f"Resource '{resource_name}' is incomplete and cannot be used. " + "Please create a PR to complete this resource, or create an issue if you " + "need help. https://github.com/PyLabRobot/pylabrobot") diff --git a/pylabrobot/resources/falcon/README.md b/pylabrobot/resources/falcon/README.md deleted file mode 100644 index 849a5d7aca..0000000000 --- a/pylabrobot/resources/falcon/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Resource definitions: Falcon - -## Tubes - -| Description | Image | PLR definition | -|--------------------|--------------------|--------------------| -| 50mL Falcon Tube [manufacturer website](https://www.fishersci.com/shop/products/falcon-50ml-conical-centrifuge-tubes-2/1495949A) | falcon tube 50mL | `falcon_tube_50mL` -| 15mL Falcon Tube [manufacturer website](https://www.fishersci.com/shop/products/falcon-15ml-conical-centrifuge-tubes-5/p-193301) | falcon tube 15mL | `falcon_tube_15mL` diff --git a/pylabrobot/resources/falcon/plates.py b/pylabrobot/resources/falcon/plates.py new file mode 100644 index 0000000000..a377c189a0 --- /dev/null +++ b/pylabrobot/resources/falcon/plates.py @@ -0,0 +1,127 @@ + +from typing import Optional + +from pylabrobot.resources.height_volume_functions import ( + compute_height_from_volume_conical_frustum, + compute_volume_from_height_conical_frustum) +from pylabrobot.resources.plate import Lid, Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import CrossSectionType, Well, WellBottomType + + +def Falcon_96_wellplate_Fl(name: str, lid: Optional[Lid] = None) -> Plate: + """ + Falcon cat. no.: 353072 + - Material: Polystyrene + """ + BOTTOM_RADIUS = 3.175 + TOP_RADIUS = 3.425 + + return Plate( + name=name, + size_x=127.76, # directly from reference manual + size_y=85.11, # directly from reference manual + size_z=14.30, # without lid, directly from reference manual + lid=lid, + model=Falcon_96_wellplate_Fl.__name__, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=11.05, # measured + dy=7.75, # measured + dz=1.11, # from reference manual + item_dx=8.99, + item_dy=8.99, + size_x=6.35, + size_y=6.35, + size_z=14.30, + bottom_type=WellBottomType.FLAT, + compute_volume_from_height=lambda liquid_height: compute_volume_from_height_conical_frustum( + liquid_height, BOTTOM_RADIUS, TOP_RADIUS + ), + compute_height_from_volume=lambda liquid_volume: compute_height_from_volume_conical_frustum( + liquid_volume, BOTTOM_RADIUS, TOP_RADIUS + ), + ), + ) + +def Falcon_96_wellplate_Rb(name: str, lid: Optional[Lid] = None) -> Plate: + """ + Falcon cat. no.: 353077 + - Material: Polystyrene + - Tissue culture treated: Yes + spec: https://www.corning.com/catalog/cls/documents/drawings/LSR00181.pdf + """ + TOP_INNER_WELL_RADIUS = 3.425 + BOTTOM_INNER_WELL_RADIUS = 3.175 + + well_kwargs = { + "size_x": BOTTOM_INNER_WELL_RADIUS * 2, + "size_y": BOTTOM_INNER_WELL_RADIUS * 2, + "size_z": 14.30, # from spec + "bottom_type": WellBottomType.U, + "max_volume": 0.25, # from spec + } + + return Plate( + name=name, + size_x=127.76, + size_y=85.11, + size_z=14.30, + lid=lid, + model=Falcon_96_wellplate_Rb.__name__, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=14.38 - TOP_INNER_WELL_RADIUS, # from spec + dy=11.39 - TOP_INNER_WELL_RADIUS, # from spec + dz=1.80, # calibrated manually by z-stepping down using a pipette. + item_dx=8.99, # measured + item_dy=8.99, # measured + **well_kwargs, + ), + ) + +def Falcon_96_wellplate_Fl_Black(name: str, lid: Optional[Lid] = None) -> Plate: + """ + Falcon Catalog No. 353219 + """ + TOP_INNER_WELL_RADIUS = 6.96 / 2 # from spec + BOTTOM_INNER_WELL_RADIUS = 6.58 / 2 # from spec + + well_kwargs = { + "size_x": TOP_INNER_WELL_RADIUS * 2, # from spec + "size_y": TOP_INNER_WELL_RADIUS * 2, # from spec + "size_z": 10.90, # from spec + "bottom_type": WellBottomType.FLAT, + "cross_section_type": CrossSectionType.CIRCLE, + "compute_volume_from_height": lambda liquid_height: compute_volume_from_height_conical_frustum( + liquid_height, BOTTOM_INNER_WELL_RADIUS, TOP_INNER_WELL_RADIUS + ), + "compute_height_from_volume": lambda liquid_volume: compute_height_from_volume_conical_frustum( + liquid_volume, BOTTOM_INNER_WELL_RADIUS, TOP_INNER_WELL_RADIUS + ), + "material_z_thickness": 0.15, # measured at 0.15 mm + } + + return Plate( + name=name, + size_x=127.76, # from spec + size_y=85.48, # from spec + size_z=14.40, # from spec + lid=lid, + model=Falcon_96_wellplate_Fl_Black.__name__, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.7, # calculated from spec, manually calibrated + dy=7.7, # calculated from spec, manually calibrated + dz=6.7, # calculated from spec, manually calibrated + item_dx=9, + item_dy=9, + **well_kwargs, + ), + ) diff --git a/pylabrobot/resources/falcon/tubes.py b/pylabrobot/resources/falcon/tubes.py index 18c64cce86..6c6a17c596 100644 --- a/pylabrobot/resources/falcon/tubes.py +++ b/pylabrobot/resources/falcon/tubes.py @@ -14,7 +14,8 @@ def falcon_tube_50mL(name: str) -> Tube: # pylint: disable=invalid-name size_y=diameter, size_z=115, model="Falcon 50mL", - max_volume=50_000 + max_volume=50_000, + material_z_thickness=1.2 ) @@ -33,3 +34,22 @@ def falcon_tube_15mL(name: str) -> Tube: # pylint: disable=invalid-name model="Falcon 15mL", max_volume=15_000 ) + + +def Falcon_tube_14mL_Rb(name: str) -> Tube: + """ 14 mL round-bottom snap-cap Falcon tube. Corning cat. no.: 352059 + + - Material: polypropylene + - bottom_type=TubeBottomType.U + - snap-cap lid + """ + # material_z_thickness = 1.2 mm + diameter = 17 + return Tube( + name=name, + size_x=diameter, + size_y=diameter, + size_z=95, + model="Falcon_tube_14mL_Rb", + max_volume=14_000 # units: ul + ) diff --git a/pylabrobot/resources/functional.py b/pylabrobot/resources/functional.py new file mode 100644 index 0000000000..0e0d4d9703 --- /dev/null +++ b/pylabrobot/resources/functional.py @@ -0,0 +1,72 @@ +import json +import logging +import os +import random +from collections import deque +from typing import AsyncGenerator, Deque, List, Optional +from pylabrobot.resources.tip_rack import TipRack, TipSpot + + +logger = logging.getLogger("pylabrobot.resources") + + +def get_all_tip_spots(tip_racks: List[TipRack]) -> List[TipSpot]: + return [spot for rack in tip_racks for spot in rack.get_all_items()] + + +async def linear_tip_spot_generator( + tip_spots: List[TipSpot], + cache_file_path: Optional[str]=None, + repeat: bool=False +) -> AsyncGenerator[TipSpot, None]: + """ Tip spot generator with disk caching. Linearly iterate through all tip spots and + raise StopIteration when all spots have been used. """ + tip_spot_idx = 0 + if cache_file_path is not None and os.path.exists(cache_file_path): + with open(cache_file_path, "r", encoding="utf-8") as f: + data = json.load(f) + tip_spot_idx = data["tip_spot_idx"] + logger.info("loaded tip idx from disk: %s", data) + + while True: + if cache_file_path is not None: + with open(cache_file_path, "w", encoding="utf-8") as f: + json.dump({"tip_spot_idx": tip_spot_idx}, f) + yield tip_spots[tip_spot_idx] + tip_spot_idx += 1 + if tip_spot_idx >= len(tip_spots): + if repeat: + tip_spot_idx = 0 + else: + return + + +async def randomized_tip_spot_generator( + tip_spots: List[TipSpot], + K: int, cache_file_path: Optional[str]=None +) -> AsyncGenerator[TipSpot, None]: + """ Randomized tip spot generator with disk caching. Don't return tip spots that have been + sampled in the last K samples. """ + + recently_sampled: Deque[str] = deque(maxlen=K) + + if cache_file_path is not None and os.path.exists(cache_file_path): + with open(cache_file_path, "r", encoding="utf-8") as f: + data = json.load(f) + recently_sampled = deque(data["recently_sampled"], maxlen=K) + logger.info("loaded recently sampled tip spots from disk: %s", recently_sampled) + + while True: + available_tips = [ts for ts in tip_spots if ts.name not in recently_sampled] + + if not available_tips: + raise RuntimeError("All tips have been used recently, resetting list.") + + chosen_tip_spot = random.choice(available_tips) + recently_sampled.append(chosen_tip_spot.name) + + if cache_file_path is not None: + with open(cache_file_path, "w", encoding="utf-8") as f: + json.dump({"recently_sampled": list(recently_sampled)}, f) + + yield chosen_tip_spot diff --git a/pylabrobot/resources/greiner/plates.py b/pylabrobot/resources/greiner/plates.py index 1ee8eb578f..a5791873ec 100644 --- a/pylabrobot/resources/greiner/plates.py +++ b/pylabrobot/resources/greiner/plates.py @@ -2,9 +2,9 @@ # pylint: disable=invalid-name -from pylabrobot.resources.plate import Plate +from pylabrobot.resources.plate import Lid, Plate from pylabrobot.resources.well import Well, WellBottomType -from pylabrobot.resources.itemized_resource import create_equally_spaced +from pylabrobot.resources.utils import create_ordered_items_2d def _compute_volume_from_height_Gre_384_Sq(h: float) -> float: @@ -14,6 +14,19 @@ def _compute_volume_from_height_Gre_384_Sq(h: float) -> float: return volume +def Gre_384_Sq_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Gre_384_Sq_Lid", + # ) + + def Gre_384_Sq(name: str, with_lid: bool = False) -> Plate: """ Gre_384_Sq """ return Plate( @@ -21,14 +34,14 @@ def Gre_384_Sq(name: str, with_lid: bool = False) -> Plate: size_x=127.0, size_y=86.0, size_z=14.5, - with_lid=with_lid, + lid=Gre_384_Sq_Lid(name + "_lid") if with_lid else None, model="Gre_384_Sq", - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=16, - dx=9.5, # dy=-1215.5, # from hamilton definition - dy=12.155, # verified empirically + dx=7 + 2.25, # (86 - 16*4.5) / 2 + half well width + dy=9.5 + 2.25, # (86 - 16*4.5) / 2 + half well width dz=2.85, item_dx=4.5, item_dy=4.5, @@ -48,6 +61,19 @@ def _compute_volume_from_height_Gre_1536_Sq(h: float) -> float: return volume +def Gre_1536_Sq_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Gre_1536_Sq_Lid", + # ) + + def Gre_1536_Sq(name: str, with_lid: bool = False) -> Plate: """ Gre_1536_Sq """ return Plate( @@ -55,9 +81,9 @@ def Gre_1536_Sq(name: str, with_lid: bool = False) -> Plate: size_x=127.0, size_y=86.0, size_z=10.4, - with_lid=with_lid, + lid=Gre_1536_Sq_Lid(name + "_lid") if with_lid else None, model="Gre_1536_Sq", - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=48, num_items_y=32, dx=9.5, @@ -82,7 +108,7 @@ def Gre_1536_Sq_L(name: str, with_lid: bool = False) -> Plate: def Gre_1536_Sq_P(name: str, with_lid: bool = False) -> Plate: """ Gre_1536_Sq """ - return Gre_1536_Sq(name=name, with_lid=with_lid).rotated(90) + return Gre_1536_Sq(name=name, with_lid=with_lid).rotated(z=90) def _compute_volume_from_height_Greiner96Well_655_101(h: float) -> float: volume = min(h, 10.9)*35.0152 if h > 10.9: @@ -90,6 +116,19 @@ def _compute_volume_from_height_Greiner96Well_655_101(h: float) -> float: return volume +def Greiner96Well_655_101_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Greiner96Well_655_101_Lid", + # ) + + # done with python # plate, description, eqn = create_plate_for_writing(path, ctr_filepath=ctr_path) # ctr_path = 'Well655_101.ctr' @@ -101,9 +140,9 @@ def Greiner96Well_655_101(name: str, with_lid: bool = False) -> Plate: size_x=127.0, size_y=86.0, size_z=14.4, - with_lid=with_lid, + lid=Greiner96Well_655_101_Lid(name + "_lid") if with_lid else None, model="Greiner96Well_655_101", - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=9.5, @@ -129,6 +168,19 @@ def _compute_volume_from_height_Greiner96Well_650_201_RB(h: float) -> float: return volume +def Greiner96Well_650_201_RB_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Greiner96Well_650_201_RB_Lid", + # ) + + def Greiner96Well_650_201_RB(name: str, with_lid: bool = False) -> Plate: """ Greiner96Well_650_201_RB """ return Plate( @@ -136,9 +188,9 @@ def Greiner96Well_650_201_RB(name: str, with_lid: bool = False) -> Plate: size_x=127.0, size_y=86.0, size_z=14.6, - with_lid=with_lid, + lid=Greiner96Well_650_201_RB_Lid(name + "_lid") if with_lid else None, model="Greiner96Well_650_201_RB", - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=9.88, diff --git a/pylabrobot/resources/hamilton/hamilton_deck_tests.py b/pylabrobot/resources/hamilton/hamilton_deck_tests.py index e7f6d7ec5b..1281d36966 100644 --- a/pylabrobot/resources/hamilton/hamilton_deck_tests.py +++ b/pylabrobot/resources/hamilton/hamilton_deck_tests.py @@ -1,81 +1,15 @@ import textwrap -from typing import cast import unittest -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.itemized_resource import ItemizedResource -from pylabrobot.resources.carrier import TipCarrier, PlateCarrier -from pylabrobot.resources.corning_costar import Cos_96_DW_1mL, Cos_96_DW_500ul -from pylabrobot.resources.hamilton import HamiltonSTARDeck, STARLetDeck +from pylabrobot.resources.corning_costar import Cor_96_wellplate_360ul_Fb +from pylabrobot.resources.stanley.cups import StanleyCup_QUENCHER_FLOWSTATE_TUMBLER +from pylabrobot.resources.hamilton import STARLetDeck from pylabrobot.resources.ml_star import STF_L, HTF_L, TIP_CAR_480_A00, PLT_CAR_L5AC_A00 -from pylabrobot.resources.resource import Resource class HamiltonDeckTests(unittest.TestCase): """ Tests for the HamiltonDeck class. """ - def test_parse_lay_file(self): - fn = "./pylabrobot/testing/test_data/test_deck.lay" - deck = HamiltonSTARDeck.load_from_lay_file(fn) - - tip_car = deck.get_resource("TIP_CAR_480_A00_0001") - assert isinstance(tip_car, TipCarrier) - - def get_item_center(name: str) -> Coordinate: - tip_rack = cast(ItemizedResource, deck.get_resource(name)) - tip = cast(Resource, tip_rack.get_item("A1")) - return tip.get_absolute_location() + tip.center() - - self.assertEqual(tip_car.get_absolute_location(), Coordinate(122.500, 63.000, 100.000)) - self.assertEqual(get_item_center("tips_01"), Coordinate(140.400, 145.800, 164.450)) - self.assertEqual(get_item_center("STF_L_0001"), Coordinate(140.400, 241.800, 164.450)) - self.assertEqual(get_item_center("tips_04"), Coordinate(140.400, 433.800, 131.450)) - - assert tip_car[0].resource is not None - self.assertEqual(tip_car[0].resource.name, "tips_01") - assert tip_car[1].resource is not None - self.assertEqual(tip_car[1].resource.name, "STF_L_0001") - self.assertIsNone(tip_car[2].resource) - assert tip_car[3].resource is not None - self.assertEqual(tip_car[3].resource.name, "tips_04") - self.assertIsNone(tip_car[4].resource) - - self.assertEqual(deck.get_resource("PLT_CAR_L5AC_A00_0001").get_absolute_location(), - Coordinate(302.500, 63.000, 100.000)) - self.assertEqual(get_item_center("Cos_96_DW_1mL_0001"), Coordinate(320.500, 146.000, 187.150)) - self.assertEqual(get_item_center("Cos_96_DW_500ul_0001"), Coordinate(320.500, 338.000, 188.150)) - self.assertEqual(get_item_center("Cos_96_DW_1mL_0002"), Coordinate(320.500, 434.000, 187.150)) - self.assertEqual(get_item_center("Cos_96_DW_2mL_0001"), Coordinate(320.500, 530.000, 187.150)) - - plt_car1 = deck.get_resource("PLT_CAR_L5AC_A00_0001") - assert isinstance(plt_car1, PlateCarrier) - assert plt_car1[0].resource is not None - self.assertEqual(plt_car1[0].resource.name, "Cos_96_DW_1mL_0001") - self.assertIsNone(plt_car1[1].resource) - assert plt_car1[2].resource is not None - self.assertEqual(plt_car1[2].resource.name, "Cos_96_DW_500ul_0001") - assert plt_car1[3].resource is not None - self.assertEqual(plt_car1[3].resource.name, "Cos_96_DW_1mL_0002") - assert plt_car1[4].resource is not None - self.assertEqual(plt_car1[4].resource.name, "Cos_96_DW_2mL_0001") - - self.assertEqual(deck.get_resource("PLT_CAR_L5AC_A00_0002").get_absolute_location(), - Coordinate(482.500, 63.000, 100.000)) - self.assertEqual(get_item_center("Cos_96_DW_1mL_0003"), Coordinate(500.500, 146.000, 187.150)) - self.assertEqual(get_item_center("Cos_96_DW_500ul_0003"), Coordinate(500.500, 242.000, 188.150)) - self.assertEqual(get_item_center("Cos_96_PCR_0001"), Coordinate(500.500, 434.000, 186.650)) - - plt_car2 = deck.get_resource("PLT_CAR_L5AC_A00_0002") - assert isinstance(plt_car2, PlateCarrier) - assert plt_car2[0].resource is not None - self.assertEqual(plt_car2[0].resource.name, "Cos_96_DW_1mL_0003") - assert plt_car2[1].resource is not None - self.assertEqual(plt_car2[1].resource.name, "Cos_96_DW_500ul_0003") - self.assertIsNone(plt_car2[2].resource) - assert plt_car2[3].resource is not None - self.assertEqual(plt_car2[3].resource.name, "Cos_96_PCR_0001") - self.assertIsNone(plt_car2[4].resource) - def build_layout(self): """ Build a deck layout for testing """ deck = STARLetDeck() @@ -86,8 +20,8 @@ def build_layout(self): tip_car[3] = HTF_L(name="tip_rack_04") plt_car = PLT_CAR_L5AC_A00(name="plate carrier") - plt_car[0] = Cos_96_DW_1mL(name="aspiration plate") - plt_car[2] = Cos_96_DW_500ul(name="dispense plate") + plt_car[0] = Cor_96_wellplate_360ul_Fb(name="aspiration plate") + plt_car[2] = Cor_96_wellplate_360ul_Fb(name="dispense plate") deck.assign_child_resource(tip_car, rails=1) deck.assign_child_resource(plt_car, rails=21) @@ -98,23 +32,34 @@ def test_summary(self): self.maxDiff = None deck = self.build_layout() self.assertEqual(deck.summary(), textwrap.dedent(""" - Rail Resource Type Coordinates (mm) - ============================================================================================== - (-13) ├── trash_core96 Trash (-232.100, 110.300, 189.000) + Rail Resource Type Coordinates (mm) + ================================================================================= + (-13) ├── trash_core96 Trash (-232.100, 110.300, 189.000) │ - (1) ├── tip_carrier TipCarrier (100.000, 063.000, 100.000) - │ ├── tip_rack_01 TipRack (117.900, 145.800, 164.450) - │ ├── tip_rack_02 TipRack (117.900, 241.800, 164.450) + (1) ├── tip_carrier TipCarrier (100.000, 063.000, 100.000) + │ ├── tip_rack_01 TipRack (106.200, 073.000, 214.950) + │ ├── tip_rack_02 TipRack (106.200, 169.000, 214.950) │ ├── - │ ├── tip_rack_04 TipRack (117.900, 433.800, 131.450) + │ ├── tip_rack_04 TipRack (106.200, 361.000, 214.950) │ ├── │ - (21) ├── plate carrier PlateCarrier (550.000, 063.000, 100.000) - │ ├── aspiration plate Plate (568.000, 146.000, 187.150) + (21) ├── plate carrier PlateCarrier (550.000, 063.000, 100.000) + │ ├── aspiration plate Plate (554.000, 071.500, 183.120) │ ├── - │ ├── dispense plate Plate (568.000, 338.000, 188.150) + │ ├── dispense plate Plate (554.000, 263.500, 183.120) │ ├── │ ├── │ - (32) ├── trash Trash (800.000, 190.600, 137.100) + (32) ├── trash Trash (800.000, 190.600, 137.100) """[1:])) + + def test_assign_gigantic_resource(self): + stanley_cup = StanleyCup_QUENCHER_FLOWSTATE_TUMBLER(name="HUGE") + deck = STARLetDeck() + with self.assertLogs() as log: + deck.assign_child_resource(stanley_cup, rails=1) + self.assertEqual(log.output, + ["WARNING:pylabrobot:Resource 'HUGE' is very high on the deck: 412.42 mm. Be " + "careful when traversing the deck.", + "WARNING:pylabrobot:Resource 'HUGE' is very high on the deck: 412.42 mm. Be " + "careful when grabbing this resource."]) diff --git a/pylabrobot/resources/hamilton/hamilton_decks.py b/pylabrobot/resources/hamilton/hamilton_decks.py index 42b144ff87..e399dca0f6 100644 --- a/pylabrobot/resources/hamilton/hamilton_decks.py +++ b/pylabrobot/resources/hamilton/hamilton_decks.py @@ -1,18 +1,15 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -import inspect import logging from typing import Optional, cast from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.resources.carrier import Carrier +from pylabrobot.resources.carrier import CarrierSite from pylabrobot.resources.deck import Deck -from pylabrobot.resources.plate import Plate from pylabrobot.resources.resource import Resource -from pylabrobot.resources.tip_rack import TipRack from pylabrobot.resources.trash import Trash -import pylabrobot.utils.file_parsing as file_parser +from pylabrobot.resources.ml_star.mfx_modules import MFXModule logger = logging.getLogger("pylabrobot") @@ -30,7 +27,7 @@ STAR_SIZE_Y=653.5 STAR_SIZE_Z=900 -def _rails_for_x_coordinate(x: int): +def _rails_for_x_coordinate(x: float) -> int: """ Convert an x coordinate to a rail identifier. """ return int((x - 100.0) / _RAILS_WIDTH) + 1 @@ -51,6 +48,7 @@ def __init__( super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, category=category, origin=origin) self.num_rails = num_rails + self.register_did_assign_resource_callback(self._check_save_z_height) @abstractmethod def rails_to_location(self, rails: int) -> Coordinate: @@ -64,6 +62,30 @@ def serialize(self) -> dict: "no_trash": True # data encoded as child. (not very pretty to have this key though...) } + def _check_save_z_height(self, resource: Resource): + """" Check for this resource, and all its children, that the z location is not too high. """ + + # TODO: maybe these are parameters per HamiltonDeck that we can take as attributes. + Z_MOVEMENT_LIMIT = 245 + Z_GRAB_LIMIT = 285 + + def check_z_height(resource: Resource): + z_top = resource.get_absolute_location(z="top").z + + if z_top > Z_MOVEMENT_LIMIT: + logger.warning("Resource '%s' is very high on the deck: %s mm. Be careful when " + "traversing the deck.", resource.name, z_top) + + if z_top > Z_GRAB_LIMIT: + logger.warning("Resource '%s' is very high on the deck: %s mm. Be careful when " + "grabbing this resource.", resource.name, z_top) + + for child in resource.children: + check_z_height(child) + + check_z_height(resource) + + def assign_child_resource( self, resource: Resource, @@ -122,10 +144,10 @@ def assign_child_resource( resource_location = None # unknown resource location if resource_location is not None: # collision detection - if resource_location.x + resource.get_size_x() > \ + if resource_location.x + resource.get_absolute_size_x() > \ self.rails_to_location(self.num_rails + 1).x and \ rails is not None: - raise ValueError(f"Resource with width {resource.get_size_x()} does not " + raise ValueError(f"Resource with width {resource.get_absolute_size_x()} does not " f"fit at rails {rails}.") # Check if there is space for this new resource. @@ -136,12 +158,14 @@ def assign_child_resource( # A resource is not allowed to overlap with another resource. Resources overlap when a # corner of one resource is inside the boundaries of another resource. if any([ - og_x <= resource_location.x < og_x + og_resource.get_size_x(), - og_x < resource_location.x + resource.get_size_x() < og_x + og_resource.get_size_x() + og_x <= resource_location.x < og_x + og_resource.get_absolute_size_x(), + og_x < resource_location.x + resource.get_absolute_size_x() \ + < og_x + og_resource.get_absolute_size_x() ]) and any( [ - og_y <= resource_location.y < og_y + og_resource.get_size_y(), - og_y < resource_location.y + resource.get_size_y() < og_y + og_resource.get_size_y() + og_y <= resource_location.y < og_y + og_resource.get_absolute_size_y(), + og_y < resource_location.y + resource.get_absolute_size_y() \ + < og_y + og_resource.get_absolute_size_y() ] ): raise ValueError(f"Location {resource_location} is already occupied by resource " @@ -149,97 +173,6 @@ def assign_child_resource( return super().assign_child_resource(resource, location=resource_location, reassign=reassign) - @classmethod - def load_from_lay_file(cls, fn: str) -> HamiltonDeck: - """ Parse a .lay file (legacy layout definition) and build the layout on this deck. - - Args: - fn: Filename of .lay file. - - Examples: - - Loading from a lay file: - - >>> from pylabrobot.resources.hamilton import HamiltonDeck - >>> deck = HamiltonSTARDeck.load_from_lay_file("deck.lay") - """ - - # pylint: disable=import-outside-toplevel, cyclic-import - import pylabrobot.resources as resources_module - - c = None - with open(fn, "r", encoding="ISO-8859-1") as f: - c = f.read() - - deck_type = file_parser.find_string("Deck", c) - - num_rails = {"ML_Starlet.dck": STARLET_NUM_RAILS, "ML_STAR2.deck": STAR_NUM_RAILS}[deck_type] - size_x = {"ML_Starlet.dck": STARLET_SIZE_X, "ML_STAR2.deck": STAR_SIZE_X}[deck_type] - size_y = {"ML_Starlet.dck": STARLET_SIZE_Y, "ML_STAR2.deck": STAR_SIZE_Y}[deck_type] - size_z = {"ML_Starlet.dck": STARLET_SIZE_Z, "ML_STAR2.deck": STAR_SIZE_Z}[deck_type] - - deck = cls(num_rails=num_rails, - size_x=size_x, size_y=size_y, size_z=size_z, - origin=Coordinate.zero()) - - # Get class names of all defined resources. - resource_classes = [c[0] for c in inspect.getmembers(resources_module)] - - # Get number of items on deck. - num_items = file_parser.find_int("Labware.Cnt", c) - - # Collect all items on deck. - - containers = {} - children = {} - - for i in range(1, num_items+1): - name = file_parser.find_string(f"Labware.{i}.Id", c) - - # get class name (generated from file name) - file_name = file_parser.find_string(f"Labware.{i}.File", c).split("\\")[-1] - class_name = None - if ".rck" in file_name: - class_name = file_name.split(".rck")[0] - elif ".tml" in file_name: - class_name = file_name.split(".tml")[0] - - if class_name in resource_classes: - klass = getattr(resources_module, class_name) - resource = klass(name=name) - else: - logger.warning( - "Resource with classname %s not found. Please file an issue at " - "https://github.com/pylabrobot/pylabrobot/issues/new?assignees=&labels=" - "&title=Deserialization%%3A%%20Class%%20%s%%20not%%20found", class_name, class_name) - continue - - # get location props - # 'default' template means resource are placed directly on the deck, otherwise it - # contains the name of the containing resource. - if file_parser.find_string(f"Labware.{i}.Template", c) == "default": - x = file_parser.find_float(f"Labware.{i}.TForm.3.X", c) - y = file_parser.find_float(f"Labware.{i}.TForm.3.Y", c) - z = file_parser.find_float(f"Labware.{i}.ZTrans", c) - resource.location = Coordinate(x=x, y=y, z=z) - containers[name] = resource - else: - children[name] = { - "container": file_parser.find_string(f"Labware.{i}.Template", c), - "site": file_parser.find_int(f"Labware.{i}.SiteId", c), - "resource": resource} - - # Assign all containers to the deck. - for cont in containers.values(): - deck.assign_child_resource(cont, location=cont.location) - - # Assign child resources to their parents. - for child in children.values(): - cont = containers[child["container"]] - cont[5 - child["site"]] = child["resource"] - - return deck - def summary(self) -> str: """ Return a summary of the deck. @@ -255,36 +188,100 @@ def summary(self) -> str: if len(self.get_all_resources()) == 0: raise ValueError( - "This liquid editor does not have any resources yet. " - "Build a layout first by calling `assign_child_resource()`. " + "This liquid editor does not have any resources yet. " + "Build a layout first by calling `assign_child_resource()`. " ) - # Print header. - summary_ = "Rail" + " " * 5 + "Resource" + " " * 19 + "Type" + " " * 16 + "Coordinates (mm)\n" - summary_ += "=" * 94 + "\n" - - def parse_resource(resource): - # TODO: print something else if resource is not assigned to a rails. - rails = _rails_for_x_coordinate(resource.location.x) - rail_label = f"({rails})" if rails is not None else " " - r_summary = f"{rail_label:5} ├── {resource.name:27}" + \ - f"{resource.__class__.__name__:20}" + \ - f"{resource.get_absolute_location()}\n" - - if isinstance(resource, Carrier): - for site in resource.get_sites(): - if site.resource is None: - r_summary += " │ ├── \n" + # don't print these + exclude_categories = {"well", "tube", "tip_spot", "carrier_site", "plate_carrier_site"} + + def find_longest_child_name(resource: Resource, depth=0, depth_weight=4): + """ DFS to find longest child name, and depth of that child, excluding excluded categories """ + l, d = (len(resource.name), depth) if resource.category not in exclude_categories else (0, 0) + new_depth = depth + 1 if resource.category not in exclude_categories else depth + return max([(l + d*depth_weight)] + + [find_longest_child_name(c, new_depth) for c in resource.children]) + + + def find_longest_type_name(resource: Resource): + """ DFS to find the longest type name """ + l = len(resource.__class__.__name__) if resource.category not in exclude_categories else 0 + return max([l] + [find_longest_type_name(child) for child in resource.children]) + + # Calculate the maximum lengths of the resource name and type for proper alignment + max_name_length = find_longest_child_name(self) + max_type_length = find_longest_type_name(self) + + # Find column lengths + rail_column_length = 6 + name_column_length = max(max_name_length + 4, 30) # 4 per depth (by find_longest_child), 4 extra + type_column_length = max_type_length + 3 - 4 + location_column_length = 30 + + # Print header + summary_ = ( + "Rail".ljust(rail_column_length) + + "Resource".ljust(name_column_length) + + "Type".ljust(type_column_length) + + "Coordinates (mm)".ljust(location_column_length) + + "\n" + ) + total_length = rail_column_length + name_column_length + type_column_length + \ + location_column_length + summary_ += "=" * total_length + "\n" + + def make_tree_part(depth: int) -> str: + tree_part = "├── " + for _ in range(depth): + tree_part = "│ " + tree_part + return tree_part + + def print_empty_spot_line(depth=0) -> str: + r_summary = " " * rail_column_length + tree_part = make_tree_part(depth) + r_summary += (tree_part + "").ljust(name_column_length) + return r_summary + + def print_resource_line(resource: Resource, depth=0) -> str: + r_summary = "" + + # Print rail + if depth == 0: + rails = _rails_for_x_coordinate(resource.get_absolute_location().x) + r_summary += f"({rails})".ljust(rail_column_length) + else: + r_summary += " " * rail_column_length + + # Print resource name + tree_part = make_tree_part(depth) + r_summary += (tree_part + resource.name).ljust(name_column_length) + + # Print resource type + r_summary += resource.__class__.__name__.ljust(type_column_length) + + # Print resource location + location = resource.get_absolute_location() + r_summary += str(location).ljust(location_column_length) + + return r_summary + + def print_tree(resource: Resource, depth=0): + r_summary = print_resource_line(resource, depth=depth) + + if isinstance(resource, MFXModule) and len(resource.children) == 0: + r_summary += "\n" + r_summary += print_empty_spot_line(depth=depth+1) + + for child in resource.children: + if isinstance(child, CarrierSite): + r_summary += "\n" + if child.resource is not None: + r_summary += print_tree(child.resource, depth=depth+1) else: - subresource = site.resource - if isinstance(subresource, (TipRack, Plate)): - location = subresource.get_item("A1").get_absolute_location() + \ - subresource.get_item("A1").center() - else: - location = subresource.get_absolute_location() - r_summary += f" │ ├── {subresource.name:23}" + \ - f"{subresource.__class__.__name__:20}" + \ - f"{location}\n" + r_summary += print_empty_spot_line(depth=depth+1) + elif child.category not in exclude_categories: + r_summary += "\n" + r_summary += print_tree(child, depth=depth+1) return r_summary @@ -292,10 +289,14 @@ def parse_resource(resource): sorted_resources = sorted(self.children, key=lambda r: r.get_absolute_location().x) # Print table body. - summary_ += parse_resource(sorted_resources[0]) + summary_ += print_tree(sorted_resources[0]) + "\n" for resource in sorted_resources[1:]: summary_ += " │\n" - summary_ += parse_resource(resource) + summary_ += print_tree(resource) + summary_ += "\n" + + # Truncate trailing whitespace from each line + summary_ = "\n".join([line.rstrip() for line in summary_.split("\n")]) return summary_ diff --git a/pylabrobot/resources/hamilton_parse.py b/pylabrobot/resources/hamilton_parse.py deleted file mode 100644 index 3051a8a600..0000000000 --- a/pylabrobot/resources/hamilton_parse.py +++ /dev/null @@ -1,448 +0,0 @@ -import os -from typing import Tuple, Optional - -from pylabrobot.resources import ( - Coordinate, - CrossSectionType, - MFXCarrier, - Plate, - PlateCarrier, - TipCarrier, - TipRack, - TipSpot, - Well, - WellBottomType, - create_equally_spaced, - create_homogeneous_carrier_sites -) -from pylabrobot.resources.ml_star.tip_creators import ( - low_volume_tip_no_filter, - low_volume_tip_with_filter, - standard_volume_tip_no_filter, - standard_volume_tip_with_filter, - high_volume_tip_no_filter, - high_volume_tip_with_filter, - four_ml_tip_with_filter, - five_ml_tip, - five_ml_tip_with_filter -) -from pylabrobot.utils.file_parsing import find_int, find_float, find_string - - -__all__ = [ - "create_plate", - "create_tip_rack", - "create_plate_carrier", - "create_tip_carrier", - "create_flex_carrier", -] - - -def get_resource_type(filepath) -> str: - """ Get the resource type from the filename or the file contents. """ - filename = os.path.basename(filepath) - if filename.startswith("PLT_CAR_"): - return "PlateCarrier" - if filename.startswith("TIP_CAR_"): - return "TipCarrier" - if filename.startswith("MFX_CAR_"): - return "MFXCarrier" - if filename.startswith("SMP_CAR_"): - raise ValueError("SMP_CAR_ not supported yet") - # return "TubeCarrier" - - if filepath.endswith("_L.rck"): - filepath = filepath.replace("_L.rck", ".rck") - if filename.endswith("_P.rck"): - filepath = filepath.replace("_P.rck", ".rck") - - if not os.path.exists(filepath): - return "TipRack" # only tip racks have no .rck file - - with open(filepath, "r", encoding="ISO-8859-1") as f: - c = f.read() - try: - category0id = find_int("Category.0.Id", c) - # based on some inspection of the files, but just a guess - if category0id in range(170, 180): - return "TipRack" - if category0id in range(1000, 1100): - return "Plate" - except ValueError: - pass - - try: - _ = find_string("Cntr.1.file", c) # only plates have a .ctr file - return "Plate" - except ValueError: - pass - - raise ValueError(f"Unknown resource type for file {filename}") - - -def create_plate_for_writing( - filepath: str, - ctr_filepath: Optional[str] = None -) -> Tuple[Plate, Optional[str], Optional[str]]: - """ Create a plate from the given file. Returns the plate and optionally a description. Also - returns a description and the volume equation. - - Args: - filepath: The path to the .rck file for the plate. - ctr_filepath: The path to the .ctr file for the plate. If not given, it will be inferred from - the .rck file. I think the ctr file is used for well definitions. - """ - with open(filepath, "r", encoding="ISO-8859-1") as f: - c = f.read() - - size_x = find_float("Dim.Dx", c) - size_y = find_float("Dim.Dy", c) - size_z = find_float("Dim.Dz", c) - - num_items_x = find_int("Columns", c) - num_items_y = find_int("Rows", c) - well_dx = find_float("Dx", c) - well_dy = find_float("Dy", c) - - # rck files use the center of the well, but we want the bottom left corner. - dx = round(find_float("BndryX", c) - well_dx/2, 4) - dy = round(find_float("BndryY", c) - well_dy/2, 4) - # dz = round(find_float("Cntr.1.base", c), 4) - - filename = os.path.basename(filepath) - cname = filename.split(".")[0] - description = cname - - if cname == "Cos_96_ProtCryst" and well_dy == 4.5: - # ad-hoc fix for Cos_96_ProtCryst, where the definition is almost certainly wrong - well_dy = 9.0 - - # .rck to .ctr filepath - def rck2ctr(fn): - return fn \ - .replace("_P.rck", ".ctr") \ - .replace("_L.rck", ".ctr") \ - .replace(".rck", ".ctr") \ - .replace("ProtCryst", "Post") - - ctr_filepath = ctr_filepath or rck2ctr(filepath) - with open(ctr_filepath, "r", encoding="ISO-8859-1") as f2: - c2 = f2.read() - num_segments = find_int("Segments", c2) - vol_eqn_func = "" - height_so_far = 0 - for i in range(num_segments, 0, -1): - vol_eqn = find_string(f"{i}.EqnOfVol", c2) - section_max_height = find_float(f"{i}.Max", c2) - if i == num_segments: # first section from bottom - vol_eqn = vol_eqn.replace("h", f"min(h, {section_max_height})") - vol_eqn_func += f"volume = {vol_eqn}\n" - else: - vol_eqn = vol_eqn.replace("h", f"(h-{height_so_far})") - vol_eqn_func += f"if h <= {section_max_height}:\n" - vol_eqn_func += f" volume += {vol_eqn}\n" - height_so_far += section_max_height - vol_eqn_func += f"if h > {height_so_far}:\n" - vol_eqn_func += f" raise ValueError(f\"Height {{h}} is too large for {cname}\")\n" - vol_eqn_func += "return volume" - - well_size_x = find_float("Dim.Dx", c2) - well_size_y = find_float("Dim.Dy", c2) - - # we can get shapes of other segments with X.Shape, X being the segment number. - # Numbered from the top, so last segment is the bottom - well_bottom_type_code = find_int(f"{num_segments}.Shape", c2) - well_bottom_type = { - 0: WellBottomType.FLAT, # cylinder - 1: WellBottomType.FLAT, # rectangle - # 2: ? # "inverted cone" - 3: WellBottomType.V, # "V-cone" - # 4 & 5 only for last segment - 4: WellBottomType.U, # "rounded base segment" - 5: WellBottomType.V, # "V-cone base segment" - }.get(well_bottom_type_code, WellBottomType.UNKNOWN) - - # The shape of the first segment is most indicative of the well shape - cross_section_type_code = find_int("1.Shape", c2) - cross_section_type = { - 0: CrossSectionType.CIRCLE, - 1: CrossSectionType.RECTANGLE, - # 2: ?? , - # 3: ?? , - # 4: ?? , - # 5: ?? , - }.get(cross_section_type_code, CrossSectionType.CIRCLE) - - well_size_z = find_float("Depth", c2) - - # probably wrong, will fix later when I do carrier site bases - # written on 2024-03-01 - try: - dz = find_float("BaseMM", c2) - except ValueError: - dz = 0 - - plate = Plate( - name=cname, - size_x=size_x, - size_y=size_y, - size_z=size_z, - num_items_x=num_items_x, - num_items_y=num_items_y, - items=create_equally_spaced( - Well, - num_items_x=num_items_x, - num_items_y=num_items_y, - dx=dx + (well_dx - well_size_x)/2, # add mini offset for border of wells - dy=dy + (well_dy - well_size_y)/2, # add mini offset for border of wells - dz=dz, - item_dx=well_dx, - item_dy=well_dy, - size_x=well_size_x, - size_y=well_size_y, - size_z=well_size_z, - bottom_type=well_bottom_type, - cross_section_type=cross_section_type - ), - lid_height=10, - model=cname - ) - - return plate, description, vol_eqn_func - - -def create_tip_rack_for_writing(filepath: str) -> Tuple[TipRack, Optional[str]]: - """ Create a tip rack from the given file. Returns the tip rack and optionally a description. Also - create a description. """ - - tip_table = { - "MlStar4mlTipWithFilter": four_ml_tip_with_filter, - "MlStar5mlTipWithFilter": five_ml_tip_with_filter, - "MlStar10ulLowVolumeTip": low_volume_tip_no_filter, - "MlStar10ulLowVolumeTipWithFilter": low_volume_tip_with_filter, - "MlStar1000ulHighVolumeTipWithFilter": high_volume_tip_with_filter, - "MlStar1000ulHighVolumeTip": high_volume_tip_no_filter, - "MlStar5mlTip": five_ml_tip, - "MlStar300ulStandardVolumeTipWithFilter": standard_volume_tip_with_filter, - "MlStar300ulStandardVolumeTip": standard_volume_tip_no_filter, - } - - with open(filepath, "r", encoding="ISO-8859-1") as f: - c = f.read() - - size_x = find_float("Dim.Dx", c) - size_y = find_float("Dim.Dy", c) - size_z = find_float("Dim.Dz", c) - tip_type = None - try: - tip_type = find_string("PropertyValue.6", c) - except ValueError: - tip_type = find_string("PropertyValue.4", c) - tip_creator = tip_table[tip_type] - - tip_size_x = find_float("Dx", c) - tip_size_y = find_float("Dy", c) - - # rck files use the center of the well, but we want the bottom left corner. - dx = round(find_float("BndryX", c) - tip_size_x/2, 4) - dy = round(find_float("BndryY", c) - tip_size_y/2, 4) - dz = find_float("Cntr.1.base", c) - - num_items_x = find_int("Columns", c) - num_items_y = find_int("Rows", c) - - cname = os.path.basename(filepath).split(".")[0] - if cname[0] == "4": - cname = "Four" + cname[1:] - elif cname[0] == "5": - cname = "Five" + cname[1:] - description = find_string("Description", c) - - tip_rack = TipRack( - name=cname, - size_x=size_x, - size_y=size_y, - size_z=size_z, - items=create_equally_spaced( - TipSpot, - num_items_x=num_items_x, - num_items_y=num_items_y, - dx=dx, - dy=dy, - dz=dz, - item_dx=tip_size_x, - item_dy=tip_size_y, - size_x=tip_size_x, - size_y=tip_size_y, - size_z=size_z, - make_tip=tip_creator - ), - model=cname - ) - - return tip_rack, description - - -def create_plate_carrier_for_writing(filepath: str) -> Tuple[PlateCarrier, Optional[str]]: - """ Create a plate carrier from the given file. Returns the plate carrier and optionally a - description. Also create a description. """ - with open(filepath, "r", encoding="ISO-8859-1") as f: - c = f.read() - - site_count = int(c.split("Site.Cnt\x01")[1].split("\x08")[0]) - sites = [] - for i in range(1, site_count+1): - x = find_float(f"Site.{i}.X", c) - y = find_float(f"Site.{i}.Y", c) - z = find_float(f"Site.{i}.Z", c) - site_width = find_float(f"Site.{i}.Dx", c) - site_height = find_float(f"Site.{i}.Dy", c) - sites.append(Coordinate(x, y, z)) - sites = sorted(sites, key=lambda c: c.y) - - size_x = find_float("Dim.Dx", c) - size_y = find_float("Dim.Dy", c) - size_z = find_float("Dim.Dz", c) - description = find_string("Description", c) - cname = os.path.basename(filepath).split(".")[0] - - plate_carrier = PlateCarrier( - name=cname, - size_x=size_x, - size_y=size_y, - size_z=size_z, - sites=create_homogeneous_carrier_sites(sites, site_size_x=site_width, site_size_y=site_height), - model=cname - ) - return plate_carrier, description - - -def create_tip_carrier_for_writing(filepath: str) -> Tuple[TipCarrier, Optional[str]]: - """ Create a tip carrier from the given file. Returns the tip carrier and optionally a - description. Also create a description. """ - with open(filepath, "r", encoding="ISO-8859-1") as f: - c = f.read() - - site_count = int(c.split("Site.Cnt\x01")[1].split("\x08")[0]) - sites = [] - for i in range(1, site_count+1): - x = find_float(f"Site.{i}.X", c) - y = find_float(f"Site.{i}.Y", c) - z = find_float(f"Site.{i}.Z", c) - site_width = find_float(f"Site.{i}.Dx", c) - site_height = find_float(f"Site.{i}.Dy", c) - sites.append(Coordinate(x, y, z)) - sites = sorted(sites, key=lambda c: c.y) - - size_x = find_float("Dim.Dx", c) - size_y = find_float("Dim.Dy", c) - size_z = find_float("Dim.Dz", c) - description = find_string("Description", c) - cname = os.path.basename(filepath).split(".")[0] - - tip_carrier = TipCarrier( - name=cname, - size_x=size_x, - size_y=size_y, - size_z=size_z, - sites=create_homogeneous_carrier_sites(sites, site_size_x=site_width, site_size_y=site_height), - model=cname - ) - return tip_carrier, description - - -def create_flex_carrier_for_writing(filepath: str) -> Tuple[MFXCarrier, Optional[str]]: - """ Create a multiflex carrier from the given file. Returns the multiflex carrier and optionally a - description. Also create a description. """ - with open(filepath, "r", encoding="ISO-8859-1") as f: - c = f.read() - - site_count = int(c.split("Site.Cnt\x02")[1].split("\x08")[0]) - sites = [] - for i in range(1, site_count+1): - x = find_float(f"Site.{i}.X", c) - y = find_float(f"Site.{i}.Y", c) - z = find_float(f"Site.{i}.Z", c) - site_width = find_float(f"Site.{i}.Dx", c) - site_height = find_float(f"Site.{i}.Dy", c) - sites.append(Coordinate(x, y, z)) - sites = sorted(sites, key=lambda c: c.y) - - # filter sites by visible - sites = [s for i, s in enumerate(sites) if find_int(f"Site.{i}.Visible", c) == 1] - - size_x = find_float("Dim.Dx", c) - size_y = find_float("Dim.Dy", c) - size_z = find_float("Dim.Dz", c) - description = find_string("Description", c) - cname = os.path.basename(filepath).split(".")[0] - - flex_carrier = MFXCarrier( - name=cname, - size_x=size_x, - size_y=size_y, - size_z=size_z, - sites=create_homogeneous_carrier_sites(sites, site_size_x=site_width, site_size_y=site_height), - model=cname - ) - return flex_carrier, description - - -def create_plate(filepath: str, name: str, ctr_filepath: Optional[str] = None) -> Plate: - """ Create a plate from the given file. - - Args: - filepath: The path to the .rck file for the plate. - name: The name of the plate resource. - """ - plate, _, _ = create_plate_for_writing(filepath, ctr_filepath=ctr_filepath) - plate.name = name - return plate - - -def create_tip_rack(filepath: str, name: str) -> TipRack: - """ Create a tip rack from the given file. - - Args: - filepath: The path to the .rck file for the tip rack. - name: The name of the tip rack resource. - """ - tip_rack, _ = create_tip_rack_for_writing(filepath) - tip_rack.name = name - return tip_rack - - -def create_plate_carrier(filepath: str, name: str) -> PlateCarrier: - """ Create a plate carrier from the given file. - - Args: - filepath: The path to the .rck file for the plate carrier. - name: The name of the plate carrier resource. - """ - plate_carrier, _ = create_plate_carrier_for_writing(filepath) - plate_carrier.name = name - return plate_carrier - - -def create_tip_carrier(filepath: str, name: str) -> TipCarrier: - """ Create a tip carrier from the given file. - - Args: - filepath: The path to the .rck file for the tip carrier. - name: The name of the tip carrier resource. - """ - tip_carrier, _ = create_tip_carrier_for_writing(filepath) - tip_carrier.name = name - return tip_carrier - - -def create_flex_carrier(filepath: str, name: str) -> MFXCarrier: - """ Create a multiflex carrier from the given file. - - Args: - filepath: The path to the .rck file for the multiflex carrier. - name: The name of the multiflex carrier resource. - """ - flex_carrier, _ = create_flex_carrier_for_writing(filepath) - flex_carrier.name = name - return flex_carrier diff --git a/pylabrobot/resources/itemized_resource.py b/pylabrobot/resources/itemized_resource.py index c4171f8471..53fb22ca31 100644 --- a/pylabrobot/resources/itemized_resource.py +++ b/pylabrobot/resources/itemized_resource.py @@ -1,10 +1,10 @@ from abc import ABCMeta import sys -from typing import Union, Tuple, TypeVar, Generic, List, Optional, Generator, Type, Sequence, cast +from typing import Dict, Union, Tuple, TypeVar, Generic, List, Optional, Generator, Sequence, cast +from string import ascii_uppercase as LETTERS import pylabrobot.utils -from .coordinate import Coordinate from .resource import Resource if sys.version_info >= (3, 8): @@ -26,14 +26,12 @@ class ItemizedResource(Resource, Generic[T], metaclass=ABCMeta): .. note:: This class is not meant to be used directly, but rather to be subclassed, most commonly by - :class:`pylabrobot.resources.Plate` and - :class:`pylabrobot.resources.TipRack`. + :class:`pylabrobot.resources.Plate` and :class:`pylabrobot.resources.TipRack`. """ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - items: Optional[List[List[T]]] = None, - num_items_x: Optional[int] = None, - num_items_y: Optional[int] = None, + ordered_items: Optional[Dict[str, T]] = None, + ordering: Optional[List[str]] = None, category: Optional[str] = None, model: Optional[str] = None): """ Initialize an itemized resource @@ -43,56 +41,58 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, size_x: The size of the resource in the x direction. size_y: The size of the resource in the y direction. size_z: The size of the resource in the z direction. - items: The items on the resource. See - :func:`pylabrobot.resources.create_equally_spaced`. Note that items - names will be prefixed with the resource name. Defaults to `[]`. - num_items_x: The number of items in the x direction. This method can only and must be used if - `items` is not specified. - num_items_y: The number of items in the y direction. This method can only and must be used if - `items` is not specified. - location: The location of the resource. + ordered_items: The items on the resource, along with their identifier (as keys). See + :func:`pylabrobot.resources.create_ordered_items_2d`. If this is specified, `ordering` must + be `None`. Keys must be in transposed MS Excel style notation, e.g. "A1" for the first item, + "B1" for the item below that, "A2" for the item to the right, etc. + ordering: The order of the items on the resource. This is a list of identifiers. If this is + specified, `ordered_items` must be `None`. See `ordered_items` for the format of the + identifiers. category: The category of the resource. Examples: Creating a plate with 96 wells with - :func:`pylabrobot.resources.create_equally_spaced`: + :func:`pylabrobot.resources.create_ordered_items_2d`: >>> from pylabrobot.resources import Plate - >>> plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=10, - ... items=create_equally_spaced(Well + >>> plate = Plate("plate", size_x=1, size_y=1, size_z=1, + ... ordered_items=create_ordered_items_2d(Well ... dx=0, dy=0, dz=0, item_size_x=1, item_size_y=1, ... num_items_x=1, num_items_y=1)) - Creating a plate with 1 well with a list: + Creating a plate with 1 Well in a dict: >>> from pylabrobot.resources import Plate - >>> plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=10, - ... items=[[Well("well", size_x=1, size_y=1, size_z=1)]]) + >>> plate = Plate("plate", size_x=1, size_y=1, size_z=1, + ... ordered_items={"A1": Well("well", size_x=1, size_y=1, size_z=1)}) """ super().__init__(name, size_x, size_y, size_z, category=category, model=model) - if items is None: - if num_items_x is None or num_items_y is None: - raise ValueError("Either items or (num_items_x and num_items_y) must be specified.") - self.num_items_x = num_items_x - self.num_items_y = num_items_y - else: - self.num_items_x = len(items) - self.num_items_y = len(items[0]) if self.num_items_x > 0 else 0 - - for row in (items or []): - for item in row: - item.name = f"{self.name}_{item.name}" - assert item.location is not None, \ - "Item location must be specified if supplied at initialization." + if ordered_items is not None: + if ordering is not None: + raise ValueError("Cannot specify both `ordered_items` and `ordering`.") + for item in ordered_items.values(): + if item.location is None: + raise ValueError("Item location must be specified if supplied at initialization.") + item.name = f"{self.name}_{item.name}" # prefix item name with resource name self.assign_child_resource(item, location=item.location) + self._ordering = list(ordered_items.keys()) + else: + if ordering is None: + raise ValueError("Must specify either `ordered_items` or `ordering`.") + self._ordering = ordering + + # validate that ordering is in the transposed Excel style notation + for identifier in self._ordering: + if not identifier[0] in LETTERS or not identifier[1:].isdigit(): + raise ValueError("Ordering must be in the transposed Excel style notation, e.g. 'A1'.") def __getitem__( self, identifier: Union[str, int, Sequence[int], Sequence[str], slice, range] - ) -> List[T]: + ) -> List[T]: """ Get the items with the given identifier. This is a convenience method for getting the items with the given identifier. It is equivalent @@ -138,11 +138,9 @@ def __getitem__( if isinstance(identifier, (slice, range)): start, stop = identifier.start, identifier.stop if isinstance(identifier.start, str): - start = pylabrobot.utils.string_to_index(identifier.start, num_rows=self.num_items_y, - num_columns=self.num_items_x) + start = self._ordering.index(identifier.start) if isinstance(identifier.stop, str): - stop = pylabrobot.utils.string_to_index(identifier.stop, num_rows=self.num_items_y, - num_columns=self.num_items_x) + stop = self._ordering.index(identifier.stop) identifier = list(range(start, stop)) return self.get_items(identifier) @@ -160,24 +158,19 @@ def get_item(self, identifier: Union[str, int, Tuple[int, int]]) -> T: to right). If a string, it uses transposed MS Excel style notation, e.g. "A1" for the first item, "B1" for the item below that, etc. If a tuple, it is (row, column). - Returns: - The item with the given identifier. - Raises: - IndexError: If the identifier is out of range. The range is 0 to (num_items_x * num_items_y - - 1). Strings are converted to integer indices first. + IndexError: If the identifier is out of range. The range is 0 to self.num_items-1 (inclusive). """ - if isinstance(identifier, str): - row, column = pylabrobot.utils.string_to_position(identifier) - if not 0 <= row < self.num_items_y or not 0 <= column < self.num_items_x: - raise IndexError(f"Identifier '{identifier}' out of range.") - identifier = row + column * self.num_items_y - elif isinstance(identifier, tuple): + if isinstance(identifier, tuple): row, column = identifier - if not 0 <= row < self.num_items_y or not 0 <= column < self.num_items_x: - raise IndexError(f"Identifier '{identifier}' out of range.") - identifier = row + column * self.num_items_y + identifier = LETTERS[row] + str(column+1) # standard transposed-Excel style notation + if isinstance(identifier, str): + try: + identifier = self._ordering.index(identifier) + except ValueError as e: + raise IndexError(f"Item with identifier '{identifier}' does not exist on " + f"resource '{self.name}'.") from e if not 0 <= identifier < self.num_items: raise IndexError(f"Item with identifier '{identifier}' does not exist on " @@ -186,18 +179,15 @@ def get_item(self, identifier: Union[str, int, Tuple[int, int]]) -> T: # Cast child to item type. Children will always be `T`, but the type checker doesn't know that. return cast(T, self.children[identifier]) - def get_items(self, identifier: Union[str, Sequence[int], Sequence[str]]) -> List[T]: + def get_items(self, identifiers: Union[str, Sequence[int], Sequence[str]]) -> List[T]: """ Get the items with the given identifier. Args: - identifier: The identifier of the items. Either a string or a list of integers. If a string, - it uses transposed MS Excel style notation, e.g. "A1" for the first item, "B1" for the item - below that, etc. Regions of items can be specified using a colon, e.g. "A1:H1" for the first - column. If a list of integers, it is the indices of the items in the list of items (counted - from 0, top to bottom, left to right). - - Returns: - The items with the given identifier. + identifier: Deprecated. Use `identifiers` instead. # TODO(deprecate-ordered-items) + identifiers: The identifiers of the items. Either a string range or a list of integers. If a + string, it uses transposed MS Excel style notation. Regions of items can be specified using + a colon, e.g. "A1:H1" for the first column. If a list of integers, it is the indices of the + items in the list of items (counted from 0, top to bottom, left to right). Examples: Getting the items with identifiers "A1" through "E1": @@ -213,20 +203,13 @@ def get_items(self, identifier: Union[str, Sequence[int], Sequence[str]]) -> Lis [, , , , ] """ - if isinstance(identifier, str): - assert ":" in identifier, \ - "If identifier is a string, it must be a range of items, e.g. 'A1:E1'." - identifier = list(pylabrobot.utils.string_to_indices(identifier, num_rows=self.num_items_y)) - elif identifier is None: - return [None] - - return [self.get_item(i) for i in identifier] + if isinstance(identifiers, str): + identifiers = pylabrobot.utils.expand_string_range(identifiers) + return [self.get_item(i) for i in identifiers] @property def num_items(self) -> int: - """ The number of items on this resource. """ - - return self.num_items_x * self.num_items_y + return len(self.children) def traverse( self, @@ -364,11 +347,55 @@ def make_generator(indices, batch_size, repeat) -> Generator[List[T], None, None return make_generator(indices, batch_size, repeat) + def __repr__(self) -> str: + return (f"{self.__class__.__name__}(name={self.name}, size_x={self._size_x}, " + f"size_y={self._size_y}, size_z={self._size_z}, location={self.location})") + + @staticmethod + def _occupied_func(item: T): + return "O" if item.children else "-" + + def make_grid(self, occupied_func=None): + # The "occupied_func" is a function that checks if a resource has something in it, + # and returns a single character representing its status. + if occupied_func is None: + occupied_func = self._occupied_func + + # Make a title with summary information. + info_str = repr(self) + + if self.num_items_y > len(LETTERS): + # TODO: This will work up to 384-well plates. + return info_str + " (too many rows to print)" + + # Calculate the maximum number of digits required for any column index. + max_digits = len(str(self.num_items_x)) + + # Create the header row with numbers aligned to the columns. + # Use right-alignment specifier. + header_row = " " + " ".join(f"{i+1:<{max_digits}}" for i in range(self.num_items_x)) + + # Create the item grid with resource absence/presence information. + item_grid = [ + [occupied_func(self.get_item((i, j))) for j in range(self.num_items_x)] + for i in range(self.num_items_y) + ] + spacer = " " * max(1, max_digits) + item_list = [LETTERS[i] + ": " + spacer.join(row) for i, row in enumerate(item_grid)] + item_text = "\n".join(item_list) + + # Simple footer with dimensions. + footer_text = f"{self.num_items_x}x{self.num_items_y} {self.__class__.__name__}" + + return info_str + "\n" + header_row + "\n" + item_text + "\n" + footer_text + + def print_grid(self, occupied_func=None): + print(self.make_grid(occupied_func=occupied_func)) + def serialize(self) -> dict: return { **super().serialize(), - "num_items_x": self.num_items_x, - "num_items_y": self.num_items_y, + "ordering": self._ordering, } def index_of_item(self, item: T) -> Optional[int]: @@ -385,46 +412,34 @@ def get_all_items(self) -> List[T]: return self.get_items(range(self.num_items)) + def _get_grid_size(self, identifiers) -> Tuple[int, int]: + """ Get the size of the grid from the identifiers, or raise an error if not a full grid. """ + rows_set, columns_set = set(), set() + for identifier in identifiers: + rows_set.add(identifier[0]) + columns_set.add(identifier[1:]) -def create_equally_spaced( - klass: Type[T], - num_items_x: int, num_items_y: int, - dx: float, dy: float, dz: float, - item_dx: float, item_dy: float, - **kwargs -) -> List[List[T]]: - """ Make equally spaced resources. - - See :class:`ItemizedResource` for more details. - - Args: - klass: The class of the resource to create - num_items_x: The number of items in the x direction - num_items_y: The number of items in the y direction - dx: The bottom left corner for items in the left column - dy: The bottom left corner for items in the top row - dz: The z coordinate for all items - item_dx: The size of the items in the x direction - item_dy: The size of the items in the y direction - **kwargs: Additional keyword arguments to pass to the resource constructor - - Returns: - A list of lists of resources. The outer list contains the columns, and the inner list contains - the items in each column. - """ + rows, columns = sorted(list(rows_set)), sorted(list(columns_set), key=int) + + expected_identifiers = sorted([c + r for c in rows for r in columns]) + if sorted(identifiers) != expected_identifiers: + raise ValueError(f"Not a full grid: {identifiers}") + return len(rows), len(columns) + + @property + def num_items_x(self) -> int: + """ The number of items in the x direction, if the resource is a full grid. If the resource is + not a full grid, an error will be raised. """ + _, num_items_x = self._get_grid_size(self._ordering) + return num_items_x - # TODO: It probably makes more sense to transpose this. - - items: List[List[T]] = [] - for i in range(num_items_x): - items.append([]) - for j in range(num_items_y): - name = f"{klass.__name__.lower()}_{i}_{j}" - item = klass( - name=name, - **kwargs - ) - item.location=Coordinate(x=dx + i * item_dx, y=dy + (num_items_y-j-1) * item_dy, z=dz) - items[i].append(item) - - return items + @property + def num_items_y(self) -> int: + """ The number of items in the y direction, if the resource is a full grid. If the resource is + not a full grid, an error will be raised. """ + num_items_y, _ = self._get_grid_size(self._ordering) + return num_items_y + + @property + def items(self) -> List[str]: + raise NotImplementedError("Deprecated.") diff --git a/pylabrobot/resources/itemized_resource_tests.py b/pylabrobot/resources/itemized_resource_tests.py index 6cbe380fbc..4c3e9b8632 100644 --- a/pylabrobot/resources/itemized_resource_tests.py +++ b/pylabrobot/resources/itemized_resource_tests.py @@ -6,7 +6,8 @@ Coordinate, Plate, Well, - create_equally_spaced + create_equally_spaced_2d, + create_ordered_items_2d ) if sys.version_info >= (3, 8): @@ -19,8 +20,8 @@ class TestItemizedResource(unittest.TestCase): """ Tests for ItemizedResource """ def setUp(self) -> None: - self.plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=10, - items=create_equally_spaced(Well, + self.plate = Plate("plate", size_x=1, size_y=1, size_z=1, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=0, dy=0, dz=0, item_dx=9, item_dy=9, @@ -186,11 +187,11 @@ def test_travserse_down_repeat(self): class TestCreateEquallySpaced(unittest.TestCase): - """ Test for create_equally_spaced function. """ + """ Test for create_ordered_items_2d function. """ def test_create_equally_spaced(self): self.maxDiff = None - equally_spaced = create_equally_spaced(Well, + equally_spaced = create_equally_spaced_2d(Well, num_items_x=3, num_items_y=2, dx=0, dy=0, dz=0, item_dx=9, item_dy=9, diff --git a/pylabrobot/resources/limbro/plates.py b/pylabrobot/resources/limbro/plates.py index 8f720ef8ad..2c83d28e99 100644 --- a/pylabrobot/resources/limbro/plates.py +++ b/pylabrobot/resources/limbro/plates.py @@ -2,9 +2,9 @@ # pylint: disable=invalid-name -from pylabrobot.resources.plate import Plate +from pylabrobot.resources.plate import Lid, Plate from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType -from pylabrobot.resources.itemized_resource import create_equally_spaced +from pylabrobot.resources.utils import create_ordered_items_2d def _compute_volume_from_height_Limbro_24_Large(h: float) -> float: @@ -14,6 +14,19 @@ def _compute_volume_from_height_Limbro_24_Large(h: float) -> float: return volume +def Limbro_24_Large_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Limbro_24_Large_Lid", + # ) + + def Limbro_24_Large(name: str, with_lid: bool = False) -> Plate: """ Limbro_24_Large """ return Plate( @@ -21,10 +34,9 @@ def Limbro_24_Large(name: str, with_lid: bool = False) -> Plate: size_x=109.0, size_y=152.0, size_z=25.0, - with_lid=with_lid, + lid=Limbro_24_Large_Lid(name + "_lid") if with_lid else None, model="Limbro_24_Large", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=4, num_items_y=6, dx=6.0, @@ -48,6 +60,19 @@ def _compute_volume_from_height_Limbro_24_Small(h: float) -> float: return volume +def Limbro_24_Small_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Limbro_24_Small_Lid", + # ) + + def Limbro_24_Small(name: str, with_lid: bool = False) -> Plate: """ Limbro_24_Small """ return Plate( @@ -55,10 +80,9 @@ def Limbro_24_Small(name: str, with_lid: bool = False) -> Plate: size_x=109.0, size_y=152.0, size_z=25.0, - with_lid=with_lid, + lid=Limbro_24_Small_Lid(name + "_lid") if with_lid else None, model="Limbro_24_Small", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=4, num_items_y=6, dx=17.5, @@ -82,6 +106,19 @@ def _compute_volume_from_height_Limbro_48_Large(h: float) -> float: return volume +def Limbro_48_Large_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Limbro_48_Large_Lid", + # ) + + def Limbro_48_Large(name: str, with_lid: bool = False) -> Plate: """ Limbro_48_Large """ return Plate( @@ -89,10 +126,9 @@ def Limbro_48_Large(name: str, with_lid: bool = False) -> Plate: size_x=109.0, size_y=152.0, size_z=25.0, - with_lid=with_lid, + lid=Limbro_48_Large_Lid(name + "_lid") if with_lid else None, model="Limbro_48_Large", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=4, num_items_y=12, dx=16.0, @@ -116,6 +152,19 @@ def _compute_volume_from_height_Limbro_96_Large(h: float) -> float: return volume +def Limbro_96_Large_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.0, + # size_y=86.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Limbro_96_Large_Lid", + # ) + + def Limbro_96_Large(name: str, with_lid: bool = False) -> Plate: """ Limbro_96_Large """ return Plate( @@ -123,10 +172,9 @@ def Limbro_96_Large(name: str, with_lid: bool = False) -> Plate: size_x=109.0, size_y=152.0, size_z=25.0, - with_lid=with_lid, + lid=Limbro_96_Large_Lid(name + "_lid") if with_lid else None, model="Limbro_96_Large", - lid_height=10, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=8, num_items_y=12, dx=9.0, diff --git a/pylabrobot/resources/ml_star/README.md b/pylabrobot/resources/ml_star/README.md deleted file mode 100644 index 926c2347eb..0000000000 --- a/pylabrobot/resources/ml_star/README.md +++ /dev/null @@ -1,39 +0,0 @@ - -## Resource defintions: "ML_STAR" - -Company history: [Hamilton Robotics history](https://www.hamiltoncompany.com/history) - -> Hamilton Robotics provides automated liquid handling workstations for the scientific community. Our portfolio includes three liquid handling platforms, small devices, consumables, and OEM solutions. - ---- - -### Currently defined tip carriers: - -| Description | Image | PLR definition | -|--------------------|--------------------|--------------------| -| 'TIP_CAR_480_A00'
Part no.: 182085
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/182085)
Carrier for 5x 96 tip (10μl, 50μl, 300μl, 1000μl) racks or 5x 24 tip (5ml) racks (6T) | TIP_CAR_480_A00 | `TIP_CAR_480_A00` | - ---- - -### Currently defined plate carriers: - -| Description | Image | PLR definition | -|--------------------|--------------------|--------------------| -| 'PLT_CAR_L5AC_A00'
Part no.: 182090
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/182090)
Carrier for 5x 96 Deep Well Plates or for 5x 384 tip racks (e.g.384HEAD_384TIPS_50μl) (6T) | PLT_CAR_L5AC_A00 | `PLT_CAR_L5AC_A00` | - ---- - -### Currently defined MFX carriers: - -| Description | Image | PLR definition | -|--------------------|--------------------|--------------------| -| 'MFX_CAR_L5_base'
Part no.: 188039
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188039)
Labware carrier base for up to 5 Multiflex Modules | MFX_CAR_L5_base | `MFX_CAR_L5_base` | - - - -#### Currently defined MFX modules: - -| Description | Image | PLR definition | -|--------------------|--------------------|--------------------| -| 'MFX_TIP_module'
Part no.: 188160 or 188040
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188040)
Module to position a high-, standard-, low volume or 5ml tip rack (but not a 384 tip rack) | MFX_TIP_module | `MFX_TIP_module` | -| 'MFX_DWP_rackbased_module'
Part no.: 188229?
[manufacturer website](https://www.hamiltoncompany.com/automated-liquid-handling/other-robotics/188229) (<-non-functional link?)
MFX DWP module rack-based | MFX_DWP_rackbased_module | `MFX_DWP_rackbased_module` | diff --git a/pylabrobot/resources/ml_star/__init__.py b/pylabrobot/resources/ml_star/__init__.py index 5a87c55c39..48371d6dc5 100644 --- a/pylabrobot/resources/ml_star/__init__.py +++ b/pylabrobot/resources/ml_star/__init__.py @@ -1,6 +1,14 @@ +from .plate_adapters import * from .plate_carriers import * -from .tip_carriers import * + from .mfx_carriers import * -from .tip_racks import * -from .tip_creators import * from .mfx_modules import * + +from .tip_carriers import * +from .tip_creators import * +from .tip_racks import * + +from .trough_carriers import * +from .troughs import * + +from .tube_carriers import * diff --git a/pylabrobot/resources/ml_star/mfx_carriers.py b/pylabrobot/resources/ml_star/mfx_carriers.py index fcd214bdd4..7bf81124e2 100644 --- a/pylabrobot/resources/ml_star/mfx_carriers.py +++ b/pylabrobot/resources/ml_star/mfx_carriers.py @@ -1,34 +1,59 @@ -""" ML Star MultiFleX (MFX) carriers """ - -# pylint: disable=empty-docstring -# pylint: disable=invalid-name -# pylint: disable=line-too-long - -from pylabrobot.resources.carrier import ( - MFXCarrier, - Coordinate, - create_homogeneous_carrier_sites -) - - -def MFX_CAR_L5_base(name: str) -> MFXCarrier: - """ Hamilton cat. no.: 188039 - Labware carrier base for up to 5 Multiflex Modules - """ - return MFXCarrier( - name=name, - size_x=135.0, - size_y=497.0, - size_z=18.195, - sites=create_homogeneous_carrier_sites([ - Coordinate(0.0, 5.0, 18.195), - Coordinate(0.0, 101.0, 18.195), - Coordinate(0.0, 197.0, 18.195), - Coordinate(0.0, 293.0, 18.195), - Coordinate(0.0, 389.0, 18.195) - ], - site_size_x=135.0, - site_size_y=94.0, - ), - model="MFX_CAR_L5_base" - ) +""" ML Star MultiFleX (MFX) carriers """ + +# pylint: disable=empty-docstring +# pylint: disable=invalid-name +# pylint: disable=line-too-long + +from pylabrobot.resources.carrier import ( + Coordinate, + CarrierSite, + MFXCarrier, + create_homogeneous_carrier_sites +) + + +def MFX_CAR_L5_base(name: str) -> MFXCarrier: + """ Hamilton cat. no.: 188039 + Labware carrier base for up to 5 Multiflex Modules + """ + return MFXCarrier( + name=name, + size_x=135.0, + size_y=497.0, + size_z=18.195, + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ + Coordinate(0.0, 5.0, 18.195), + Coordinate(0.0, 101.0, 18.195), + Coordinate(0.0, 197.0, 18.195), + Coordinate(0.0, 293.0, 18.195), + Coordinate(0.0, 389.0, 18.195) + ], + site_size_x=135.0, + site_size_y=94.0, + ), + model="MFX_CAR_L5_base" + ) + + +def MFX_CAR_L4_SHAKER(name: str) -> MFXCarrier: + """ Hamilton cat. no.: 187001 + Sometimes referred to as "PLT_CAR_L4_SHAKER" by Hamilton. + Template carrier with 4 positions for Hamilton Heater Shaker. + Occupies 7 tracks (7T). Can be screwed onto the deck. + """ + return MFXCarrier( + name=name, + size_x=157.5, + size_y=497.0, + size_z=8.0, + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ + Coordinate(6.0, 2, 8.0), # not tested, interpolated Coordinate + Coordinate(6.0, 123, 8.0), # not tested, interpolated Coordinate + Coordinate(6.0, 244.0, 8.0), # tested using Hamilton_HC + Coordinate(6.0, 365.0, 8.0), # tested using Hamilton_HS + ], + site_size_x=145.5, + site_size_y=104.0, + ), + model="PLT_CAR_L4_SHAKER" + ) diff --git a/pylabrobot/resources/ml_star/mfx_modules.py b/pylabrobot/resources/ml_star/mfx_modules.py index bf2d66401a..8c0db9c123 100644 --- a/pylabrobot/resources/ml_star/mfx_modules.py +++ b/pylabrobot/resources/ml_star/mfx_modules.py @@ -19,23 +19,25 @@ class MFXModule(Resource): Examples: 1. Creating MFX module for tips: - Creating a `MFXCarrier`, - Creating a `MFXModule` for tips, - Assigning the `MFXModule` for tips to a carrier_site on the `MFXCarrier`, - Creating and assigning a tip_rack to the MFXsite on the MFXModule: + Create a `MFXCarrier`, + Create `MFXModule` for tips, + Assign the `MFXModule` for tips to a carrier_site on the `MFXCarrier`, + Create and assign a tip_rack to the MFXModule: >>> mfx_carrier_1 = MFX_CAR_L5_base(name='mfx_carrier_1') >>> mfx_carrier_1[0] = mfx_tip_module_1 = MFX_TIP_module(name="mfx_tip_module_1") - >>> mfx_tip_module_1[0] = tip_50ul_rack = TIP_50ul_L(name="tip_50ul_rack") + >>> tip_50ul_rack = TIP_50ul_L(name="tip_50ul_rack") + >>> mfx_tip_module_1.assign_child_resource(tip_50ul_rack) 2. Creating MFX module for plates: Use the same `MFXCarrier` instance, - Creating a `MFXModule` for plates, - Assigning the `MFXModule` for plates to a carrier_site on the `MFXCarrier`, - Creating and assigning a plate to the MFXsite on the MFXModule: + Create a `MFXModule` for plates, + Assign the `MFXModule` for plates to a carrier_site on the `MFXCarrier`, + Create and assign a plate directly to the MFXModule: - >>> mfx_carrier_1[1] = mfx_dwp_module_1 = MFX_DWP_module(name="mfx_dwp_module_1") - >>> mfx_dwp_module_1[0] = Cos96_plate_1 = Cos_96_Rd(name='Cos96_plate_1') + >>> mfx_carrier_1[1] = mfx_dwp_module_1 = MFX_DWP_rackbased_module(name="mfx_dwp_module_1") + >>> Cos96_plate_1 = Cos_96_Rd(name='cos96_plate_1') + >>> mfx_dwp_module_1.assign_child_resource(Cos96_plate_1) """ def __init__( @@ -44,12 +46,15 @@ def __init__( size_x: float, size_y: float, size_z: float, child_resource_location: Coordinate, category: Optional[str] = "mfx_module", + pedestal_size_z: Optional[float] = None, model: Optional[str] = None): super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, category=category, model=model) # site where resources will be placed on this module self._child_resource_location = child_resource_location self._child_resource: Optional[Resource] = None + self.pedestal_size_z: Optional[float] = pedestal_size_z + # TODO: add self.pedestal_2D_offset if necessary in the future @property def child_resource_location(self) -> Coordinate: @@ -63,6 +68,10 @@ def assign_child_resource( ): """ Assign a resource to a site on this module. If `location` is not provided, the resource will be placed at `self._child_resource_location` (wrt this module's left front bottom). """ + + # TODO: add conditional logic to modify Plate position based on whether + # pedestal_size_z>plate_true_dz OR pedestal_z MFXModule: Module to position a Deep Well Plate / tube racks (MATRIX or MICRONICS) / NUNC reagent trough. """ - # site_size_x=127.0, - # site_size_y=86.0, + # site_size_x=127.76, + # site_size_y=85.48, return MFXModule( name=name, size_x=135.0, size_y=94.0, - size_z=178.73-18.195-100, + size_z=178.0-18.195-100, # probe height - carrier_height - deck_height - child_resource_location=Coordinate(4.0, 3.5, 178.73-18.195-100), + child_resource_location=Coordinate(4.0, 3.5, 178.0-18.195-100), model="MFX_TIP_module", ) diff --git a/pylabrobot/resources/ml_star/plate_carriers.py b/pylabrobot/resources/ml_star/plate_carriers.py index 859c7a6db4..a17c986980 100644 --- a/pylabrobot/resources/ml_star/plate_carriers.py +++ b/pylabrobot/resources/ml_star/plate_carriers.py @@ -4,7 +4,50 @@ # pylint: disable=invalid-name # pylint: disable=line-too-long -from pylabrobot.resources.carrier import PlateCarrier, Coordinate, create_homogeneous_carrier_sites +from pylabrobot.resources.carrier import Coordinate, PlateCarrierSite, PlateCarrier, \ + create_homogeneous_carrier_sites + + + +def PLT_CAR_L4_HHS_ALT_A00(name: str) -> PlateCarrier: + """ Carrier with 4 Hamilton Heater Shakers 3.0mm with AutoLys adapters """ + return PlateCarrier( + name=name, + size_x=157.5, + size_y=497.0, + size_z=83.7, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(14.25, 14.75, 83.0), + Coordinate(14.25, 134.75, 83.0), + Coordinate(14.25, 254.75, 83.0), + Coordinate(14.25, 374.75, 83.0), + ], + site_size_x=127.0, + site_size_y=86.0, + ), + model="PLT_CAR_L4_HHS-ALT_A00" + ) + + +def PLT_CAR_L5_ALT_A00(name: str) -> PlateCarrier: + """ Deep well carrier with five AutoLys tube racks """ + return PlateCarrier( + name=name, + size_x=135.0, + size_y=497.0, + size_z=130.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(4.0, 9.5, 75.5), + Coordinate(4.0, 105.5, 75.5), + Coordinate(4.0, 201.5, 75.5), + Coordinate(4.0, 297.5, 75.5), + Coordinate(4.0, 393.5, 75.5), + ], + site_size_x=127.0, + site_size_y=86.0, + ), + model="PLT_CAR_L5_ALT_A00" + ) def PLT_CAR_L4HD(name: str) -> PlateCarrier: @@ -14,11 +57,11 @@ def PLT_CAR_L4HD(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(4.1, 36.1, 118.25), Coordinate(4.1, 146.1, 118.25), Coordinate(4.1, 256.1, 118.25), - Coordinate(4.1, 366.1, 118.25) + Coordinate(4.1, 366.1, 118.25), ], site_size_x=127.0, site_size_y=86.0, @@ -34,12 +77,12 @@ def PLT_CAR_L5AC(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(4.0, 8.5, 86.15), Coordinate(4.0, 104.5, 86.15), Coordinate(4.0, 200.5, 86.15), Coordinate(4.0, 296.5, 86.15), - Coordinate(4.0, 392.5, 86.15) + Coordinate(4.0, 392.5, 86.15), ], site_size_x=127.0, site_size_y=86.0, @@ -49,21 +92,24 @@ def PLT_CAR_L5AC(name: str) -> PlateCarrier: def PLT_CAR_L5AC_A00(name: str) -> PlateCarrier: - """ Carrier for 5 deep well 96 Well PCR Plates """ + """ Carrier for 5 deep well 96 Well PCR Plates + Hamilton cat. no.: 182090 + """ return PlateCarrier( name=name, size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(4.0, 8.5, 86.15), Coordinate(4.0, 104.5, 86.15), Coordinate(4.0, 200.5, 86.15), Coordinate(4.0, 296.5, 86.15), - Coordinate(4.0, 392.5, 86.15) + Coordinate(4.0, 392.5, 86.15), ], site_size_x=127.0, site_size_y=86.0, + pedestal_size_z=-4.74 ), model="PLT_CAR_L5AC_A00" ) @@ -76,12 +122,12 @@ def PLT_CAR_L5FLEX_AC(name: str) -> PlateCarrier: size_x=157.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(15.25, 8.5, 89.1), Coordinate(15.25, 104.5, 89.1), Coordinate(15.25, 200.5, 89.1), Coordinate(15.25, 296.5, 89.1), - Coordinate(15.25, 392.5, 89.1) + Coordinate(15.25, 392.5, 89.1), ], site_size_x=127.0, site_size_y=86.0, @@ -97,12 +143,12 @@ def PLT_CAR_L5FLEX_AC_A00(name: str) -> PlateCarrier: size_x=157.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(15.25, 8.5, 89.1), Coordinate(15.25, 104.5, 89.1), Coordinate(15.25, 200.5, 89.1), Coordinate(15.25, 296.5, 89.1), - Coordinate(15.25, 392.5, 89.1) + Coordinate(15.25, 392.5, 89.1), ], site_size_x=127.0, site_size_y=86.0, @@ -111,27 +157,6 @@ def PLT_CAR_L5FLEX_AC_A00(name: str) -> PlateCarrier: ) -def PLT_CAR_L5FLEX_MD(name: str) -> PlateCarrier: - """ Plate carrier with 5 adjustable (height) positions for MTP """ - return PlateCarrier( - name=name, - size_x=157.5, - size_y=497.0, - size_z=130.0, - sites=create_homogeneous_carrier_sites([ - Coordinate(15.25, 8.5, 115.8), - Coordinate(15.25, 104.5, 115.8), - Coordinate(15.25, 200.5, 115.8), - Coordinate(15.25, 296.5, 115.8), - Coordinate(15.25, 392.5, 115.8) - ], - site_size_x=127.0, - site_size_y=86.0, - ), - model="PLT_CAR_L5FLEX_MD" - ) - - def PLT_CAR_L5FLEX_MD_A00(name: str) -> PlateCarrier: """ Plate carrier with 5 adjustable (height) positions for MTP """ return PlateCarrier( @@ -139,15 +164,16 @@ def PLT_CAR_L5FLEX_MD_A00(name: str) -> PlateCarrier: size_x=157.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(15.25, 8.5, 115.8), Coordinate(15.25, 104.5, 115.8), Coordinate(15.25, 200.5, 115.8), Coordinate(15.25, 296.5, 115.8), - Coordinate(15.25, 392.5, 115.8) + Coordinate(15.25, 392.5, 115.8), ], site_size_x=127.0, site_size_y=86.0, + pedestal_size_z=-4.8, ), model="PLT_CAR_L5FLEX_MD_A00" ) @@ -160,15 +186,16 @@ def PLT_CAR_L5MD(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(4.0, 8.5, 111.75), Coordinate(4.0, 104.5, 111.75), Coordinate(4.0, 200.5, 111.75), Coordinate(4.0, 296.5, 111.75), - Coordinate(4.0, 392.5, 111.75) + Coordinate(4.0, 392.5, 111.75), ], site_size_x=127.0, site_size_y=86.0, + pedestal_size_z=6.55, ), model="PLT_CAR_L5MD" ) @@ -181,15 +208,16 @@ def PLT_CAR_L5MD_A00(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(4.0, 8.5, 111.75), Coordinate(4.0, 104.5, 111.75), Coordinate(4.0, 200.5, 111.75), Coordinate(4.0, 296.5, 111.75), - Coordinate(4.0, 392.5, 111.75) + Coordinate(4.0, 392.5, 111.75), ], site_size_x=127.0, site_size_y=86.0, + pedestal_size_z=6.55, ), model="PLT_CAR_L5MD_A00" ) @@ -202,12 +230,12 @@ def PLT_CAR_L5PCR(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(4.0, 8.5, 107.5), Coordinate(4.0, 104.5, 107.5), Coordinate(4.0, 200.5, 107.5), Coordinate(4.0, 296.5, 107.5), - Coordinate(4.0, 392.5, 107.5) + Coordinate(4.0, 392.5, 107.5), ], site_size_x=127.0, site_size_y=86.0, @@ -223,12 +251,12 @@ def PLT_CAR_L5PCR_A00(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(5.0, 9.5, 109.2), Coordinate(5.0, 105.5, 109.2), Coordinate(5.0, 201.5, 109.2), Coordinate(5.0, 297.5, 109.2), - Coordinate(5.0, 393.5, 109.2) + Coordinate(5.0, 393.5, 109.2), ], site_size_x=127.0, site_size_y=86.0, @@ -244,12 +272,12 @@ def PLT_CAR_L5PCR_A01(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(5.0, 9.5, 109.2), Coordinate(5.0, 105.5, 109.2), Coordinate(5.0, 201.5, 109.2), Coordinate(5.0, 297.5, 109.2), - Coordinate(5.0, 393.5, 109.2) + Coordinate(5.0, 393.5, 109.2), ], site_size_x=127.0, site_size_y=86.0, @@ -265,10 +293,10 @@ def PLT_CAR_P3AC_A00(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(43.85, 37.5, 86.15), Coordinate(43.85, 183.5, 86.15), - Coordinate(43.85, 329.5, 86.15) + Coordinate(43.85, 329.5, 86.15), ], site_size_x=86.0, site_size_y=127.0, @@ -284,10 +312,10 @@ def PLT_CAR_P3AC_A01(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(44.1, 37.5, 85.9), Coordinate(44.1, 183.5, 85.9), - Coordinate(44.1, 329.5, 85.9) + Coordinate(44.1, 329.5, 85.9), ], site_size_x=86.0, site_size_y=127.0, @@ -303,10 +331,10 @@ def PLT_CAR_P3HD(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(43.9, 27.05, 117.65), Coordinate(43.9, 173.05, 117.65), - Coordinate(43.9, 319.05, 117.65) + Coordinate(43.9, 319.05, 117.65), ], site_size_x=86.0, site_size_y=127.0, @@ -322,10 +350,10 @@ def PLT_CAR_P3MD(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(44.1, 37.5, 111.5), Coordinate(44.1, 183.5, 111.5), - Coordinate(44.1, 329.5, 111.5) + Coordinate(44.1, 329.5, 111.5), ], site_size_x=86.0, site_size_y=127.0, @@ -341,10 +369,10 @@ def PLT_CAR_P3MD_A00(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(44.1, 37.5, 111.5), Coordinate(44.1, 183.5, 111.5), - Coordinate(44.1, 329.5, 111.5) + Coordinate(44.1, 329.5, 111.5), ], site_size_x=86.0, site_size_y=127.0, @@ -360,10 +388,10 @@ def PLT_CAR_P3MD_A01(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(44.1, 37.5, 111.5), Coordinate(44.1, 183.5, 111.5), - Coordinate(44.1, 329.5, 111.5) + Coordinate(44.1, 329.5, 111.5), ], site_size_x=86.0, site_size_y=127.0, @@ -372,6 +400,187 @@ def PLT_CAR_P3MD_A01(name: str) -> PlateCarrier: ) +def PLT_CAR_L5AC_P_A00(name: str) -> PlateCarrier: + """ Carrier with pin for 5 Hamilton 96 Deep Well Plates """ + return PlateCarrier( + name=name, + size_x=135.0, + size_y=497.0, + size_z=130.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(4.0, 8.5, 86.15), + Coordinate(4.0, 104.5, 86.15), + Coordinate(4.0, 200.5, 86.15), + Coordinate(4.0, 296.5, 86.15), + Coordinate(4.0, 392.5, 86.15), + ], + site_size_x=126.8, + site_size_y=85.8, + ), + model="PLT_CAR_L5AC_P_A00" + ) + + +def PLT_CAR_P3LI_A00(name: str) -> PlateCarrier: + """ Carrier for 3 Limbro Plates portrait """ + return PlateCarrier( + name=name, + size_x=112.5, + size_y=497.0, + size_z=130.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(2.5, 12.2, 111.6), + Coordinate(2.5, 166.6, 111.6), + Coordinate(2.5, 321.6, 111.6), + ], + site_size_x=109.0, + site_size_y=152.0, + pedestal_size_z=0, + ), + model="PLT_CAR_P3LI_A00" + ) + + +def PLT_CAR_L4ST_B00(name: str) -> PlateCarrier: + """ Stack carrier with 4 empty plate stacks for 5 plates """ + return PlateCarrier( + name=name, + size_x=157.5, + size_y=497.0, + size_z=90.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(15.25, 52.0, 18.0), + Coordinate(15.25, 159.0, 18.0), + Coordinate(15.25, 266.0, 18.0), + Coordinate(15.25, 373.0, 18.0), + ], + site_size_x=127.0, + site_size_y=86.0, + ), + model="PLT_CAR_L4ST_B00" + ) + + +def PLT_CAR_L4ST_B00_4x5_Nunc96(name: str) -> PlateCarrier: + """ Stack carrier with 4 stacks, preloaded with 5 Nunc 96 plates per stack """ + return PlateCarrier( + name=name, + size_x=157.5, + size_y=497.0, + size_z=90.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(15.25, 52.0, 18.0), + Coordinate(15.25, 159.0, 18.0), + Coordinate(15.25, 266.0, 18.0), + Coordinate(15.25, 373.0, 18.0), + ], + site_size_x=127.0, + site_size_y=86.0, + ), + model="PLT_CAR_L4ST_B00_4x5_Nunc96" + ) + + +def PLT_CAR_L4ST_C00(name: str) -> PlateCarrier: + """ Stack carrier with 4 empty plate stacks for 4 plates on a platform """ + return PlateCarrier( + name=name, + size_x=157.5, + size_y=497.0, + size_z=140.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(15.25, 52.0, 86.0), + Coordinate(15.25, 159.0, 86.0), + Coordinate(15.25, 266.0, 86.0), + Coordinate(15.25, 373.0, 86.0), + ], + site_size_x=127.0, + site_size_y=86.0, + ), + model="PLT_CAR_L4ST_C00" + ) + + +def PLT_CAR_L4ST_HIGH_A00(name: str) -> PlateCarrier: + """ Stack carrier with 4 stacks for 5 plates on a platform """ + return PlateCarrier( + name=name, + size_x=157.5, + size_y=497.0, + size_z=140.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(15.25, 52.0, 73.0), + Coordinate(15.25, 159.0, 73.0), + Coordinate(15.25, 266.0, 73.0), + Coordinate(15.25, 373.0, 73.0), + ], + site_size_x=127.0, + site_size_y=86.0, + ), + model="PLT_CAR_L4ST_HIGH_A00" + ) + + +def PLT_CAR_L4ST_HIGH_A00_4x5_Nunc96(name: str) -> PlateCarrier: + """ Stack carrier with 4 stacks, preloaded with 5 Nunc 96 plates per stack (on a platform) """ + return PlateCarrier( + name=name, + size_x=157.5, + size_y=497.0, + size_z=140.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(15.25, 52.0, 73.0), + Coordinate(15.25, 159.0, 73.0), + Coordinate(15.25, 266.0, 73.0), + Coordinate(15.25, 373.0, 73.0), + ], + site_size_x=127.0, + site_size_y=86.0, + ), + model="PLT_CAR_L4ST_HIGH_A00_4x5_Nunc96" + ) + + +def PLT_CAR_L4ST_LOW_A00(name: str) -> PlateCarrier: + """ Stack carrier with 4 stacks for 9 plates (platform removed) """ + return PlateCarrier( + name=name, + size_x=157.5, + size_y=497.0, + size_z=140.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(15.25, 52.0, 18.0), + Coordinate(15.25, 159.0, 18.0), + Coordinate(15.25, 266.0, 18.0), + Coordinate(15.25, 373.0, 18.0), + ], + site_size_x=127.0, + site_size_y=86.0, + ), + model="PLT_CAR_L4ST_LOW_A00" + ) + + +def PLT_CAR_L4ST_LOW_A00_4x9_Nunc96(name: str) -> PlateCarrier: + """ Stack carrier with 4 stacks, preloaded with 9 Nunc 96 plates per stack """ + return PlateCarrier( + name=name, + size_x=157.5, + size_y=497.0, + size_z=140.0, + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ + Coordinate(15.25, 52.0, 18.0), + Coordinate(15.25, 159.0, 18.0), + Coordinate(15.25, 266.0, 18.0), + Coordinate(15.25, 373.0, 18.0), + ], + site_size_x=127.0, + site_size_y=86.0, + ), + model="PLT_CAR_L4ST_LOW_A00_4x9_Nunc96" + ) + + def PLT_CAR_L5_DWP(name: str) -> PlateCarrier: """ Carrier with Molded Corner Locators for 5 Deep Well Plates (93522-01) """ return PlateCarrier( @@ -379,7 +588,7 @@ def PLT_CAR_L5_DWP(name: str) -> PlateCarrier: size_x=135.0, size_y=497.0, size_z=100.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(4.1, 8.2, 82.1), Coordinate(4.1, 104.2, 82.1), Coordinate(4.1, 200.2, 82.1), diff --git a/pylabrobot/resources/ml_star/tip_carriers.py b/pylabrobot/resources/ml_star/tip_carriers.py index 15ff331200..29dc61fe29 100644 --- a/pylabrobot/resources/ml_star/tip_carriers.py +++ b/pylabrobot/resources/ml_star/tip_carriers.py @@ -3,7 +3,7 @@ # pylint: disable=invalid-name # pylint: disable=line-too-long -from pylabrobot.resources.carrier import TipCarrier, create_homogeneous_carrier_sites +from pylabrobot.resources.carrier import CarrierSite, TipCarrier, create_homogeneous_carrier_sites from pylabrobot.resources.coordinate import Coordinate @@ -14,12 +14,12 @@ def TIP_CAR_120BC_4mlTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -35,12 +35,12 @@ def TIP_CAR_120BC_5mlT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -56,10 +56,10 @@ def TIP_CAR_288_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(26.3, 36.3, 114.9), Coordinate(26.3, 182.213, 114.9), - Coordinate(26.3, 328.213, 114.9) + Coordinate(26.3, 328.213, 114.9), ], site_size_x=82.6, site_size_y=122.4, @@ -75,10 +75,10 @@ def TIP_CAR_288_B00(name: str) -> TipCarrier: size_x=112.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(17.1, 36.25, 115.15), Coordinate(17.1, 182.25, 115.15), - Coordinate(17.1, 328.25, 115.15) + Coordinate(17.1, 328.25, 115.15), ], site_size_x=82.6, site_size_y=122.4, @@ -94,10 +94,10 @@ def TIP_CAR_288_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -113,10 +113,10 @@ def TIP_CAR_288_HTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(26.3, 36.3, 114.95), Coordinate(26.3, 182.213, 114.95), - Coordinate(26.3, 328.213, 114.95) + Coordinate(26.3, 328.213, 114.95), ], site_size_x=82.6, site_size_y=122.4, @@ -132,10 +132,10 @@ def TIP_CAR_288_HTF_B00(name: str) -> TipCarrier: size_x=112.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(17.1, 36.25, 115.15), Coordinate(17.1, 182.25, 115.15), - Coordinate(17.1, 328.25, 115.15) + Coordinate(17.1, 328.25, 115.15), ], site_size_x=82.6, site_size_y=122.4, @@ -151,10 +151,10 @@ def TIP_CAR_288_HTF_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -170,10 +170,10 @@ def TIP_CAR_288_HT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(26.3, 36.3, 114.95), Coordinate(26.3, 182.213, 114.95), - Coordinate(26.3, 328.213, 114.95) + Coordinate(26.3, 328.213, 114.95), ], site_size_x=82.6, site_size_y=122.4, @@ -189,10 +189,10 @@ def TIP_CAR_288_HT_B00(name: str) -> TipCarrier: size_x=112.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(17.1, 36.25, 115.15), Coordinate(17.1, 182.25, 115.15), - Coordinate(17.1, 328.25, 115.15) + Coordinate(17.1, 328.25, 115.15), ], site_size_x=82.6, site_size_y=122.4, @@ -208,10 +208,10 @@ def TIP_CAR_288_HT_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -227,10 +227,10 @@ def TIP_CAR_288_LTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(26.3, 36.3, 114.95), Coordinate(26.3, 182.213, 114.95), - Coordinate(26.3, 328.213, 114.95) + Coordinate(26.3, 328.213, 114.95), ], site_size_x=82.6, site_size_y=122.4, @@ -246,10 +246,10 @@ def TIP_CAR_288_LTF_B00(name: str) -> TipCarrier: size_x=112.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(17.1, 36.25, 115.15), Coordinate(17.1, 182.25, 115.15), - Coordinate(17.1, 328.25, 115.15) + Coordinate(17.1, 328.25, 115.15), ], site_size_x=82.6, site_size_y=122.4, @@ -265,10 +265,10 @@ def TIP_CAR_288_LTF_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -284,10 +284,10 @@ def TIP_CAR_288_LT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(26.3, 36.3, 114.9), Coordinate(26.3, 182.213, 114.95), - Coordinate(26.3, 328.213, 114.95) + Coordinate(26.3, 328.213, 114.95), ], site_size_x=82.6, site_size_y=122.4, @@ -303,10 +303,10 @@ def TIP_CAR_288_LT_B00(name: str) -> TipCarrier: size_x=112.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(17.1, 36.25, 115.15), Coordinate(17.1, 182.25, 115.15), - Coordinate(17.1, 328.25, 115.15) + Coordinate(17.1, 328.25, 115.15), ], site_size_x=82.6, site_size_y=122.4, @@ -322,10 +322,10 @@ def TIP_CAR_288_LT_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -341,10 +341,10 @@ def TIP_CAR_288_STF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(26.3, 36.3, 114.95), Coordinate(26.3, 182.213, 114.95), - Coordinate(26.3, 328.213, 114.95) + Coordinate(26.3, 328.213, 114.95), ], site_size_x=82.6, site_size_y=122.4, @@ -360,10 +360,10 @@ def TIP_CAR_288_STF_B00(name: str) -> TipCarrier: size_x=112.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(17.1, 36.25, 115.15), Coordinate(17.1, 182.25, 115.15), - Coordinate(17.1, 328.25, 115.15) + Coordinate(17.1, 328.25, 115.15), ], site_size_x=82.6, site_size_y=122.4, @@ -379,10 +379,10 @@ def TIP_CAR_288_STF_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -398,10 +398,10 @@ def TIP_CAR_288_ST_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(26.3, 36.3, 114.95), Coordinate(26.3, 182.213, 114.95), - Coordinate(26.3, 328.213, 114.95) + Coordinate(26.3, 328.213, 114.95), ], site_size_x=82.6, site_size_y=122.4, @@ -417,10 +417,10 @@ def TIP_CAR_288_ST_B00(name: str) -> TipCarrier: size_x=112.5, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(17.1, 36.25, 115.15), Coordinate(17.1, 182.25, 115.15), - Coordinate(17.1, 328.25, 115.15) + Coordinate(17.1, 328.25, 115.15), ], site_size_x=82.6, site_size_y=122.4, @@ -436,10 +436,10 @@ def TIP_CAR_288_ST_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -455,10 +455,10 @@ def TIP_CAR_288_TIP_50ulF_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -474,10 +474,10 @@ def TIP_CAR_288_TIP_50ul_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -493,11 +493,11 @@ def TIP_CAR_384BC_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -513,11 +513,11 @@ def TIP_CAR_384BC_HTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -533,11 +533,11 @@ def TIP_CAR_384BC_HT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -553,11 +553,11 @@ def TIP_CAR_384BC_LTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -573,11 +573,11 @@ def TIP_CAR_384BC_LT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -593,11 +593,11 @@ def TIP_CAR_384BC_STF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -613,11 +613,11 @@ def TIP_CAR_384BC_ST_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -633,11 +633,11 @@ def TIP_CAR_384BC_TIP_50ulF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -653,11 +653,11 @@ def TIP_CAR_384BC_TIP_50ul_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -673,11 +673,11 @@ def TIP_CAR_384_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -693,11 +693,11 @@ def TIP_CAR_384_HT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -713,11 +713,11 @@ def TIP_CAR_384_LTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -733,11 +733,11 @@ def TIP_CAR_384_LT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -753,11 +753,11 @@ def TIP_CAR_384_STF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -773,11 +773,11 @@ def TIP_CAR_384_ST_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -793,11 +793,11 @@ def TIP_CAR_384_TIP_50ulF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -813,11 +813,11 @@ def TIP_CAR_384_TIP_50ul_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -833,12 +833,12 @@ def TIP_CAR_480(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -854,12 +854,12 @@ def TIP_CAR_480BC_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -875,12 +875,12 @@ def TIP_CAR_480BC_HTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -896,12 +896,12 @@ def TIP_CAR_480BC_HT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -917,12 +917,12 @@ def TIP_CAR_480BC_LTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -938,12 +938,12 @@ def TIP_CAR_480BC_LT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -959,12 +959,12 @@ def TIP_CAR_480BC_PiercingTip150ulFilter_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -980,12 +980,12 @@ def TIP_CAR_480BC_PiercingTips_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1001,12 +1001,12 @@ def TIP_CAR_480BC_STF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1022,12 +1022,12 @@ def TIP_CAR_480BC_ST_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1043,12 +1043,12 @@ def TIP_CAR_480BC_SlimTips300ulFilter_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1064,12 +1064,12 @@ def TIP_CAR_480BC_SlimTips_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1085,12 +1085,12 @@ def TIP_CAR_480BC_TIP_50ulF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1106,12 +1106,12 @@ def TIP_CAR_480BC_TIP_50ul_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1127,12 +1127,12 @@ def TIP_CAR_480_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1148,12 +1148,12 @@ def TIP_CAR_480_HTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1169,12 +1169,12 @@ def TIP_CAR_480_HT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1190,12 +1190,12 @@ def TIP_CAR_480_LTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1211,12 +1211,12 @@ def TIP_CAR_480_LT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1232,12 +1232,12 @@ def TIP_CAR_480_STF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1253,12 +1253,12 @@ def TIP_CAR_480_ST_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1274,12 +1274,12 @@ def TIP_CAR_480_TIP_50ulF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1295,12 +1295,12 @@ def TIP_CAR_480_TIP_50ul_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 114.95), Coordinate(6.2, 106.0, 114.95), Coordinate(6.2, 202.0, 114.95), Coordinate(6.2, 298.0, 114.95), - Coordinate(6.2, 394.0, 114.95) + Coordinate(6.2, 394.0, 114.95), ], site_size_x=122.4, site_size_y=82.6, @@ -1316,10 +1316,10 @@ def TIP_CAR_72_4mlTF_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -1335,10 +1335,10 @@ def TIP_CAR_72_5mlT_C00(name: str) -> TipCarrier: size_x=90.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.7, 36.3, 114.7), Coordinate(3.7, 182.3, 114.7), - Coordinate(3.7, 328.3, 114.7) + Coordinate(3.7, 328.3, 114.7), ], site_size_x=82.6, site_size_y=122.4, @@ -1354,11 +1354,11 @@ def TIP_CAR_96BC_4mlTF_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -1374,11 +1374,11 @@ def TIP_CAR_96BC_5mlT_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.3, 78.2, 114.8), Coordinate(6.3, 163.1, 114.8), Coordinate(6.3, 248.1, 114.8), - Coordinate(6.3, 333.1, 114.8) + Coordinate(6.3, 333.1, 114.8), ], site_size_x=122.4, site_size_y=82.6, @@ -1394,12 +1394,12 @@ def TIP_CAR_NTR_A00(name: str) -> TipCarrier: size_x=135.0, size_y=497.0, size_z=130.0, - sites=create_homogeneous_carrier_sites([ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.2, 10.0, 29.0), Coordinate(6.2, 106.0, 29.0), Coordinate(6.2, 202.0, 29.0), Coordinate(6.2, 298.0, 29.0), - Coordinate(6.2, 394.0, 29.0) + Coordinate(6.2, 394.0, 29.0), ], site_size_x=122.4, site_size_y=82.6, diff --git a/pylabrobot/resources/ml_star/tip_creators.py b/pylabrobot/resources/ml_star/tip_creators.py index b67e47297a..052e180a23 100644 --- a/pylabrobot/resources/ml_star/tip_creators.py +++ b/pylabrobot/resources/ml_star/tip_creators.py @@ -119,7 +119,7 @@ def standard_volume_tip_no_filter() -> HamiltonTip: ) def standard_volume_tip_with_filter() -> HamiltonTip: - """ Low volume tip without a filter (`tt01` in venus) """ + """ Standard volume tip without a filter (`tt01` in venus) """ return HamiltonTip( has_filter=True, total_tip_length=59.9, # 60 in the ctr file, but 59.9 in the log file (519+80)/10 @@ -128,8 +128,18 @@ def standard_volume_tip_with_filter() -> HamiltonTip: pickup_method=TipPickupMethod.OUT_OF_RACK ) +def slim_standard_volume_tip_with_filter() -> HamiltonTip: + """ Slim standard volume tip without a filter """ + return HamiltonTip( + has_filter=True, + total_tip_length=94.8, + maximal_volume=360, + tip_size=TipSize.HIGH_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK + ) + def low_volume_tip_no_filter() -> HamiltonTip: - """ Standard volume tip with a filter (`tt02` in venus) """ + """ Low volume tip with a filter (`tt02` in venus) """ return HamiltonTip( has_filter=False, total_tip_length=29.9, @@ -168,6 +178,26 @@ def high_volume_tip_with_filter() -> HamiltonTip: pickup_method=TipPickupMethod.OUT_OF_RACK ) +def wide_high_volume_tip_with_filter() -> HamiltonTip: + """ Wide bore (1.20 mm) high volume tip with a filter, Hamilton P/N 235677 """ + return HamiltonTip( + has_filter=True, + total_tip_length=91.95, # Measured + maximal_volume=1065, + tip_size=TipSize.HIGH_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK + ) + +def ultrawide_high_volume_tip_with_filter() -> HamiltonTip: + """ Ultra wide bore (3.20 mm) high volume tip with a filter, Hamilton P/N 235541 """ + return HamiltonTip( + has_filter=True, + total_tip_length=80.0, # Measured + maximal_volume=1065, + tip_size=TipSize.HIGH_VOLUME, + pickup_method=TipPickupMethod.OUT_OF_RACK + ) + def four_ml_tip_with_filter() -> HamiltonTip: """ 4mL tip with a filter (`tt29` in venus) """ return HamiltonTip( diff --git a/pylabrobot/resources/ml_star/tip_racks.py b/pylabrobot/resources/ml_star/tip_racks.py index 9c1de29314..157840c7ca 100644 --- a/pylabrobot/resources/ml_star/tip_racks.py +++ b/pylabrobot/resources/ml_star/tip_racks.py @@ -2,11 +2,12 @@ # pylint: skip-file -from pylabrobot.resources.itemized_resource import create_equally_spaced -from pylabrobot.resources.tip_rack import TipRack, TipSpot +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.tip_rack import TipSpot, TipRack, NestedTipRack from .tip_creators import ( low_volume_tip_no_filter, low_volume_tip_with_filter, + slim_standard_volume_tip_with_filter, standard_volume_tip_no_filter, standard_volume_tip_with_filter, high_volume_tip_no_filter, @@ -14,19 +15,21 @@ four_ml_tip_with_filter, five_ml_tip, fifty_ul_tip_with_filter, - fifty_ul_tip_no_filter + fifty_ul_tip_no_filter, + ultrawide_high_volume_tip_with_filter, + wide_high_volume_tip_with_filter ) -#: Tip Rack 24x 4ml Tip with Filter landscape oriented def FourmlTF_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack 24x 4ml Tip with Filter landscape oriented """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=7.0, model="FourmlTF_L", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=6, num_items_y=4, dx=7.3, @@ -42,20 +45,20 @@ def FourmlTF_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Tip Rack 24x 4ml Tip with Filter portrait oriented def FourmlTF_P(name: str, with_tips: bool = True) -> TipRack: - return FourmlTF_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack 24x 4ml Tip with Filter portrait oriented """ + return FourmlTF_L(name=name, with_tips=with_tips).rotated(z=90) -#: Tip Rack 24x 5ml Tip landscape oriented def FivemlT_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack 24x 5ml Tip landscape oriented """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=7.0, model="FivemlT_L", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=6, num_items_y=4, dx=7.3, @@ -71,20 +74,20 @@ def FivemlT_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Tip Rack 24x 5ml Tip portrait oriented def FivemlT_P(name: str, with_tips: bool = True) -> TipRack: - return FivemlT_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack 24x 5ml Tip portrait oriented """ + return FivemlT_L(name=name, with_tips=with_tips).rotated(z=90) -#: Rack with 96 1000ul High Volume Tip with filter def HTF_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 1000ul High Volume Tip with filter """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=20.0, model="HTF_L", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.2, @@ -100,20 +103,67 @@ def HTF_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Rack with 96 1000ul High Volume Tip with filter (portrait) +def HTF_L_WIDE(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 1000ul High Volume Tip with filter """ + return TipRack( + name=name, + size_x=122.4, + size_y=82.6, + size_z=20.0, + model=HTF_L_WIDE.__name__, + ordered_items=create_ordered_items_2d(TipSpot, + num_items_x=12, + num_items_y=8, + dx=7.2, + dy=5.3, + dz=-80.35, + item_dx=9.0, + item_dy=9.0, + size_x=9.0, + size_y=9.0, + make_tip=wide_high_volume_tip_with_filter, + ), + with_tips=with_tips + ) + +def HTF_L_ULTRAWIDE(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 1000ul High Volume Tip with filter """ + return TipRack( + name=name, + size_x=122.4, + size_y=82.6, + size_z=20.0, + model=HTF_L_ULTRAWIDE.__name__, + ordered_items=create_ordered_items_2d(TipSpot, + num_items_x=12, + num_items_y=8, + dx=7.2, + dy=5.3, + dz=-68.4, + item_dx=9.0, + item_dy=9.0, + size_x=9.0, + size_y=9.0, + make_tip=ultrawide_high_volume_tip_with_filter, + ), + with_tips=with_tips + ) + + def HTF_P(name: str, with_tips: bool = True) -> TipRack: - return HTF_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack with 96 1000ul High Volume Tip with filter (portrait) """ + return HTF_L(name=name, with_tips=with_tips).rotated(z=90) -#: Rack with 96 1000ul High Volume Tip def HT_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 1000ul High Volume Tip """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=20.0, model="HT_L", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.2, @@ -129,20 +179,20 @@ def HT_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Rack with 96 1000ul High Volume Tip (portrait) def HT_P(name: str, with_tips: bool = True) -> TipRack: - return HT_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack with 96 1000ul High Volume Tip (portrait) """ + return HT_L(name=name, with_tips=with_tips).rotated(z=90) -#: Rack with 96 10ul Low Volume Tip with filter def LTF_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 10ul Low Volume Tip with filter """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=20.0, model="LTF_L", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.2, @@ -158,20 +208,20 @@ def LTF_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Rack with 96 10ul Low Volume Tip with filter (portrait) def LTF_P(name: str, with_tips: bool = True) -> TipRack: - return LTF_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack with 96 10ul Low Volume Tip with filter (portrait) """ + return LTF_L(name=name, with_tips=with_tips).rotated(z=90) -#: Rack with 96 10ul Low Volume Tip def LT_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 10ul Low Volume Tip """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=20.0, model="LT_L", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.2, @@ -187,20 +237,20 @@ def LT_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Rack with 96 10ul Low Volume Tip (portrait) def LT_P(name: str, with_tips: bool = True) -> TipRack: - return LT_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack with 96 10ul Low Volume Tip (portrait) """ + return LT_L(name=name, with_tips=with_tips).rotated(z=90) -#: Rack with 96 300ul Standard Volume Tip with filter def STF_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 300ul Standard Volume Tip with filter """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=20.0, model="STF_L", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.2, @@ -216,20 +266,48 @@ def STF_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Rack with 96 300ul Standard Volume Tip with filter (portrait) def STF_P(name: str, with_tips: bool = True) -> TipRack: - return STF_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack with 96 300ul Standard Volume Tip with filter (portrait) """ + return STF_L(name=name, with_tips=with_tips).rotated(z=90) + + +def STF_Slim_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 300ul Slim Standard Volume Tip with filter """ + return TipRack( + name=name, + size_x=122.4, + size_y=82.6, + size_z=20.0, + model=STF_Slim_L.__name__, + ordered_items=create_ordered_items_2d(TipSpot, + num_items_x=12, + num_items_y=8, + dx=7.2, + dy=5.3, + dz=-83.5, + item_dx=9.0, + item_dy=9.0, + size_x=9.0, + size_y=9.0, + make_tip=slim_standard_volume_tip_with_filter, + ), + with_tips=with_tips + ) + +def STF_Slim_P(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 300ul Slim Standard Volume Tip with filter (portrait) """ + return STF_Slim_L(name=name, with_tips=with_tips).rotated(z=90) -#: Rack with 96 300ul Standard Volume Tip def ST_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 300ul Standard Volume Tip """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=20.0, model="ST_L", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.2, @@ -245,20 +323,20 @@ def ST_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Rack with 96 300ul Standard Volume Tip (portrait) def ST_P(name: str, with_tips: bool = True) -> TipRack: - return ST_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack with 96 300ul Standard Volume Tip (portrait) """ + return ST_L(name=name, with_tips=with_tips).rotated(z=90) -#: Rack with 96 50ul Tip with filter def TIP_50ul_w_filter_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 50ul Tip with filter """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=18.0, model="TIP_50ul_w_filter", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.2, @@ -274,20 +352,20 @@ def TIP_50ul_w_filter_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Tip Rack 96 50ul Tip with filter portrait oriented def TIP_50ul_w_filter_P(name: str, with_tips: bool = True) -> TipRack: - return TIP_50ul_w_filter_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack with 96 50ul Tip with filter (portrait) """ + return TIP_50ul_w_filter_L(name=name, with_tips=with_tips).rotated(z=90) -#: Rack with 96 50ul Tip def TIP_50ul_L(name: str, with_tips: bool = True) -> TipRack: + """ Tip Rack with 96 50ul Tip """ return TipRack( name=name, size_x=122.4, size_y=82.6, size_z=18.0, model="TIP_50ul", - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.2, @@ -303,6 +381,40 @@ def TIP_50ul_L(name: str, with_tips: bool = True) -> TipRack: ) -#: Tip Rack 96 50ul Tip portrait oriented def TIP_50ul_P(name: str, with_tips: bool = True) -> TipRack: - return TIP_50ul_L(name=name, with_tips=with_tips).rotated(90) + """ Tip Rack with 96 50ul Tip (portrait) """ + return TIP_50ul_L(name=name, with_tips=with_tips).rotated(z=90) + + +def Hamilton_96_tiprack_50ul_NTR(name: str, with_tips: bool = True) -> NestedTipRack: + """ Nested Tip Rack with 96 50ul Tip """ + return NestedTipRack( + name=name, + size_x=127.76, + size_y=85.48, + size_z=56.0, # Hamilton_96_tiprack_50ul_NTR + TIP_50ul_L.fitting_depth + model="Hamilton_96_tiprack_50ul_NTR", + stacking_z_height=16.0, + ordered_items=create_ordered_items_2d(TipSpot, + num_items_x=12, + num_items_y=8, + dx=9.45, + dy=7.55, + dz=56.0-40.5-2, + # top of Hamilton_96_tiprack_50ul_NTR - TIP_50ul_L.max_tip_length - "inbetween-space"(?) + item_dx=9.0, + item_dy=9.0, + size_x=8.15, + size_y=8.15, + make_tip=fifty_ul_tip_no_filter, + ), + with_tips=with_tips + ) + +def Hamilton_96_tiprack_50ul_NTR_L(name: str, with_tips: bool = True) -> NestedTipRack: + """ Nested Tip Rack with 96 50ul Tip (landscape, i.e. default) """ + return Hamilton_96_tiprack_50ul_NTR(name=name, with_tips=with_tips) + +def Hamilton_96_tiprack_50ul_NTR_P(name: str, with_tips: bool = True) -> NestedTipRack: + """ Nested Tip Rack with 96 50ul Tip (portrait) """ + return Hamilton_96_tiprack_50ul_NTR(name=name, with_tips=with_tips).rotated(z=90) diff --git a/pylabrobot/resources/opentrons/__init__.py b/pylabrobot/resources/opentrons/__init__.py index 29f9fd0405..feede3bbc3 100644 --- a/pylabrobot/resources/opentrons/__init__.py +++ b/pylabrobot/resources/opentrons/__init__.py @@ -1,5 +1,14 @@ from .deck import OTDeck + from .load import load_opentrons_resource, load_shared_opentrons_resource + from .plates import * +from .plate_adapters import * + + +from .module import OTModule + +from .reservoirs import * + from .tip_racks import * from .tube_racks import * diff --git a/pylabrobot/resources/opentrons/deck.py b/pylabrobot/resources/opentrons/deck.py index 2628ac811f..e84a364c86 100644 --- a/pylabrobot/resources/opentrons/deck.py +++ b/pylabrobot/resources/opentrons/deck.py @@ -19,7 +19,7 @@ def __init__(self, size_x: float = 624.3, size_y: float = 565.2, size_z: float = self.slots: List[Optional[Resource]] = [None] * 12 - self.slot_locations = [ + self.slot_locations=[ Coordinate(x=0.0, y=0.0, z=0.0), Coordinate(x=132.5, y=0.0, z=0.0), Coordinate(x=265.0, y=0.0, z=0.0), @@ -150,7 +150,7 @@ def _get_slot_name(slot: int) -> str: return name.ljust(length) summary_ = f""" - Deck: {self.get_size_x()}mm x {self.get_size_y()}mm + Deck: {self.get_absolute_size_x()}mm x {self.get_absolute_size_y()}mm +-----------------+-----------------+-----------------+ | | | | diff --git a/pylabrobot/resources/opentrons/deck_tests.py b/pylabrobot/resources/opentrons/deck_tests.py index c64690433e..b56ddf661b 100644 --- a/pylabrobot/resources/opentrons/deck_tests.py +++ b/pylabrobot/resources/opentrons/deck_tests.py @@ -3,7 +3,7 @@ from pylabrobot.resources.opentrons.deck import OTDeck from pylabrobot.resources.opentrons.tip_racks import opentrons_96_tiprack_300ul -from pylabrobot.resources.corning_costar.plates import Cos_96_EZWash +from pylabrobot.resources.corning_costar.plates import Cor_96_wellplate_360ul_Fb class TestOTDeck(unittest.TestCase): @@ -15,8 +15,8 @@ def setUp(self) -> None: self.deck.assign_child_at_slot(opentrons_96_tiprack_300ul("tip_rack_1"), 7) self.deck.assign_child_at_slot(opentrons_96_tiprack_300ul("tip_rack_2"), 8) self.deck.assign_child_at_slot(opentrons_96_tiprack_300ul("tip_rack_3"), 9) - self.deck.assign_child_at_slot(Cos_96_EZWash("my_plate"), 4) - self.deck.assign_child_at_slot(Cos_96_EZWash("my_other_plate"), 5) + self.deck.assign_child_at_slot(Cor_96_wellplate_360ul_Fb("my_plate"), 4) + self.deck.assign_child_at_slot(Cor_96_wellplate_360ul_Fb("my_other_plate"), 5) def test_summary(self): self.assertEqual(self.deck.summary(), textwrap.dedent(""" diff --git a/pylabrobot/resources/opentrons/load.py b/pylabrobot/resources/opentrons/load.py index 066c89111a..264932e18b 100644 --- a/pylabrobot/resources/opentrons/load.py +++ b/pylabrobot/resources/opentrons/load.py @@ -1,6 +1,6 @@ import math import json -from typing import Union, List, TYPE_CHECKING, cast +from typing import Dict, List, Union, TYPE_CHECKING, cast try: import opentrons_shared_data.labware @@ -14,7 +14,7 @@ from pylabrobot.resources.tip_rack import TipRack, TipSpot from pylabrobot.resources.tube import Tube from pylabrobot.resources.tube_rack import TubeRack -from pylabrobot.resources.well import Well +from pylabrobot.resources.well import Well, CrossSectionType if TYPE_CHECKING: @@ -40,9 +40,12 @@ def ot_definition_to_resource( size_y = data["dimensions"]["yDimension"] size_z = data["dimensions"]["zDimension"] - if display_category in ["wellPlate", "tipRack", "tubeRack"]: + tube_rack_display_cats = {"adapter", "aluminumBlock", "tubeRack"} + + if display_category in ["wellPlate", "tipRack", "tubeRack", "adapter", "aluminumBlock", + "reservoir"]: items = data["ordering"] - wells: List[List[Union[TipSpot, Well, Tube]]] = [] # TODO: can we use TypeGuard? + wells: List[Union[TipSpot, Well, Tube]] = [] # TODO: can we use TypeGuard? def volume_from_name(name: str) -> float: # like "Opentrons 96 Filter Tip Rack 200 µL" @@ -52,8 +55,7 @@ def volume_from_name(name: str) -> float: volume *= 1000 return float(volume) - for i, column in enumerate(items): - wells.append([]) + for column in items: for item in column: well_data = data["wells"][item] @@ -69,17 +71,29 @@ def volume_from_name(name: str) -> float: well_size_z = well_data["depth"] - location=Coordinate(x=well_data["x"], y=well_data["y"], z=well_data["z"]) + location=Coordinate( + x=well_data["x"] - well_size_x/2, + y=well_data["y"] - well_size_y/2, + z=well_data["z"] + ) + if display_category == "wellPlate": + if well_data["shape"] == "rectangular": + cross_section_type = CrossSectionType.RECTANGLE + else: + cross_section_type = CrossSectionType.CIRCLE + well = Well( name=item, size_x=well_size_x, size_y=well_size_y, size_z=well_size_z, - max_volume=well_data["totalLiquidVolume"] + material_z_thickness=None, # not known for OT labware + max_volume=well_data["totalLiquidVolume"], + cross_section_type=cross_section_type ) well.location = location - wells[i].append(well) + wells.append(well) elif display_category == "tipRack": # closure def make_make_tip(well_data) -> TipCreator: @@ -100,8 +114,8 @@ def make_tip() -> Tip: make_tip=make_make_tip(well_data) ) tip_spot.location = location - wells[i].append(tip_spot) - elif display_category == "tubeRack": + wells.append(tip_spot) + elif display_category in tube_rack_display_cats: tube = Tube( name=item, size_x=well_size_x, @@ -110,7 +124,27 @@ def make_tip() -> Tip: max_volume=well_data["totalLiquidVolume"] ) tube.location = location - wells[i].append(tube) + wells.append(tube) + elif display_category == "reservoir": + if well_data["shape"] == "rectangular": + cross_section_type = CrossSectionType.RECTANGLE + else: + cross_section_type = CrossSectionType.CIRCLE + + well = Well( + name=item, + size_x=well_size_x, + size_y=well_size_y, + size_z=well_size_z, + max_volume=well_data["totalLiquidVolume"], + cross_section_type=cross_section_type + ) + well.location = location + wells.append(well) + + ordering = data["ordering"] + flattened_ordering = [item for sublist in ordering for item in sublist] + ordered_items = dict(zip(flattened_ordering, wells)) if display_category == "wellPlate": return Plate( @@ -118,7 +152,7 @@ def make_tip() -> Tip: size_x=size_x, size_y=size_y, size_z=size_z, - items=cast(List[List[Well]], wells), + ordered_items=cast(Dict[str, Well], ordered_items), model=data["metadata"]["displayName"] ) if display_category == "tipRack": @@ -127,16 +161,27 @@ def make_tip() -> Tip: size_x=size_x, size_y=size_y, size_z=size_z, - items=cast(List[List[TipSpot]], wells), + ordered_items=cast(Dict[str, TipSpot], ordered_items), model=data["metadata"]["displayName"] ) - if display_category == "tubeRack": + if display_category in tube_rack_display_cats: + # Implemented for aluminum block adapters for temperature controlling module + # https://shop.opentrons.com/aluminum-block-set/ return TubeRack( name=name, size_x=size_x, size_y=size_y, size_z=size_z, - items=cast(List[List[Tube]], wells), + ordered_items=cast(Dict[str, Tube], ordered_items), + model=data["metadata"]["displayName"] + ) + if display_category == "reservoir": + return Plate( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + ordered_items=cast(Dict[str, Well], ordered_items), model=data["metadata"]["displayName"] ) @@ -163,7 +208,7 @@ def load_opentrons_resource(fn: str, name: str) -> Union[Plate, TipRack, TubeRac Load a tip rack: >>> from pylabrobot.resources.opentrons import load_opentrons_resource - >>> load_opentron_resource("opentrons/definitions/2/96_standard.json", "96Standard") + >>> load_opentrons_resource("opentrons/definitions/2/96_standard.json", "96Standard") """ diff --git a/pylabrobot/resources/opentrons/tube_racks.py b/pylabrobot/resources/opentrons/tube_racks.py index c9e0af0d78..6f48217144 100644 --- a/pylabrobot/resources/opentrons/tube_racks.py +++ b/pylabrobot/resources/opentrons/tube_racks.py @@ -139,3 +139,23 @@ def opentrons_24_tuberack_generic_2ml_screwcap(name: str) -> TubeRack: version=1 )) +def opentrons_96_well_aluminum_block(name: str) -> TubeRack: + return cast(TubeRack, load_shared_opentrons_resource( + definition="opentrons_96_well_aluminum_block", + name=name, + version=1 + )) + +def opentrons_24_aluminumblock_generic_2ml_screwcap(name: str) -> TubeRack: + return cast(TubeRack, load_shared_opentrons_resource( + definition="opentrons_24_aluminumblock_generic_2ml_screwcap", + name=name, + version=2 + )) + +def opentrons_24_aluminumblock_nest_1point5ml_snapcap(name: str) -> TubeRack: + return cast(TubeRack, load_shared_opentrons_resource( + definition="opentrons_24_aluminumblock_nest_1.5ml_snapcap", + name=name, + version=1 + )) diff --git a/pylabrobot/resources/petri_dish.py b/pylabrobot/resources/petri_dish.py index ff9b0e75c3..6dcef3df12 100644 --- a/pylabrobot/resources/petri_dish.py +++ b/pylabrobot/resources/petri_dish.py @@ -1,4 +1,4 @@ -from typing import Optional, cast +from typing import Callable, Optional, cast from .container import Container from .coordinate import Coordinate @@ -13,16 +13,24 @@ def __init__( name: str, diameter: float, height: float, + material_z_thickness: Optional[float] = None, category: str = "petri_dish", - model: Optional[str] = None + model: Optional[str] = None, + max_volume: Optional[float] = None, + compute_volume_from_height: Optional[Callable[[float], float]] = None, + compute_height_from_volume: Optional[Callable[[float], float]] = None, ): super().__init__( name=name, size_x=diameter, size_y=diameter, size_z=height, + material_z_thickness=material_z_thickness, category=category, model=model, + max_volume=max_volume, + compute_volume_from_height=compute_volume_from_height, + compute_height_from_volume=compute_height_from_volume, ) self.diameter = diameter self.height = height @@ -45,8 +53,8 @@ class PetriDishHolder(Resource): def __init__( self, name: str, - size_x: float = 127.0, - size_y: float = 86.0, + size_x: float = 127.76, + size_y: float = 85.48, size_z: float = 14.5, category: str = "petri_dish_holder", model: Optional[str] = None @@ -60,11 +68,7 @@ def __init__( model=model, ) - def assign_child_resource( - self, - resource: Resource, - location: Coordinate, - reassign: bool = True): + def assign_child_resource(self, resource: Resource, location: Coordinate, reassign: bool = True): """ Can only assign a single PetriDish """ if not isinstance(resource, PetriDish): raise TypeError("Can only assign PetriDish to PetriDishHolder") diff --git a/pylabrobot/resources/petri_dish_tests.py b/pylabrobot/resources/petri_dish_tests.py index cd2680b42d..af200560b3 100644 --- a/pylabrobot/resources/petri_dish_tests.py +++ b/pylabrobot/resources/petri_dish_tests.py @@ -1,24 +1,31 @@ from typing import cast import unittest -from pylabrobot.resources import PetriDish, PetriDishHolder +from pylabrobot.resources import Coordinate, PetriDish, PetriDishHolder class TestPetriDish(unittest.TestCase): """ Test the PetriDish and PetriDishHolder classes """ def test_petri_dish_serialization(self): - petri_dish = PetriDish("petri_dish", 90.0, 15.0) + petri_dish = PetriDish("petri_dish", diameter=90.0, height=15.0) serialized = petri_dish.serialize() self.assertEqual(serialized, { "name": "petri_dish", "category": "petri_dish", "diameter": 90.0, "height": 15.0, + "material_z_thickness": None, + "compute_volume_from_height": None, + "compute_height_from_volume": None, "parent_name": None, "type": "PetriDish", "children": [], "location": None, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0 + }, "max_volume": 121500.0, "model": None, }) @@ -29,68 +36,38 @@ def test_petri_dish_holder_serialization(self): self.assertEqual(serialized, { "name": "petri_dish_holder", "category": "petri_dish_holder", - "size_x": 127.0, - "size_y": 86.0, + "size_x": 127.76, + "size_y": 85.48, "size_z": 14.5, "parent_name": None, "type": "PetriDishHolder", "children": [], "location": None, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0 + }, "model": None, }) def test_petri_dish_holder_deserialization_without_dish(self): - petri_dish_holder = PetriDishHolder.deserialize({ - "name": "petri_dish_holder", - "category": "petri_dish_holder", - "size_x": 127.0, - "size_y": 86.0, - "size_z": 14.5, - "children": [], - "location": None, - "model": None, - "type": "PetriDishHolder", - "parent_name": None, - }) + petri_dish_holder = PetriDishHolder("petri_dish_holder") + petri_dish_holder = PetriDishHolder.deserialize(petri_dish_holder.serialize()) self.assertEqual(petri_dish_holder.name, "petri_dish_holder") - self.assertEqual(petri_dish_holder.get_size_x(), 127.0) - self.assertEqual(petri_dish_holder.get_size_y(), 86.0) + self.assertEqual(petri_dish_holder.get_absolute_size_x(), 127.76) + self.assertEqual(petri_dish_holder.get_absolute_size_y(), 85.48) def test_petri_dish_holder_deserialization_with_dish(self): - petri_dish_holder = PetriDishHolder.deserialize({ - "name": "petri_dish_holder", - "category": "petri_dish_holder", - "size_x": 127.0, - "size_y": 86.0, - "size_z": 14.5, - "children": [ - { - "name": "petri_dish", - "category": "petri_dish", - "diameter": 90.0, - "height": 15.0, - "parent_name": "petri_dish_holder", - "type": "PetriDish", - "children": [], - "location": { - "x": 0.0, - "y": 0.0, - "z": 0.0, - }, - "model": None, - } - ], - "location": None, - "model": None, - "type": "PetriDishHolder", - "parent_name": None, - }) + petri_dish_holder = PetriDishHolder("petri_dish_holder") + petri_dish_holder.assign_child_resource(PetriDish("petri_dish", 90.0, 15.0), + location=Coordinate.zero()) + petri_dish_holder = PetriDishHolder.deserialize(petri_dish_holder.serialize()) self.assertEqual(petri_dish_holder.name, "petri_dish_holder") - self.assertEqual(petri_dish_holder.get_size_x(), 127.0) - self.assertEqual(petri_dish_holder.get_size_y(), 86.0) - self.assertEqual(petri_dish_holder.get_size_z(), 14.5) + self.assertEqual(petri_dish_holder.get_absolute_size_x(), 127.76) + self.assertEqual(petri_dish_holder.get_absolute_size_y(), 85.48) + self.assertEqual(petri_dish_holder.get_absolute_size_z(), 14.5) self.assertEqual(len(petri_dish_holder.children), 1) dish = cast(PetriDish, petri_dish_holder.children[0]) diff --git a/pylabrobot/resources/plate.py b/pylabrobot/resources/plate.py index d4c8fa0327..7d95e10d55 100644 --- a/pylabrobot/resources/plate.py +++ b/pylabrobot/resources/plate.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import List, Optional, Sequence, Tuple, Union, cast +from typing import Dict, List, Optional, Sequence, Tuple, Union, cast, Literal + +from pylabrobot.resources.resource_holder import ResourceHolderMixin from .liquid import Liquid @@ -21,7 +23,9 @@ def __init__( size_x: float, size_y: float, size_z: float, - category: str = "lid" + nesting_z_height: float, + category: str = "lid", + model: Optional[str] = None ): """ Create a lid for a plate. @@ -30,11 +34,19 @@ def __init__( size_x: Size of the lid in x-direction. size_y: Size of the lid in y-direction. size_z: Size of the lid in z-direction. + nesting_z_height: the overlap in mm between the lid and its parent plate (in the z-direction). """ - super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, category=category) + super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z,category=category, + model=model) + self.nesting_z_height = nesting_z_height + if nesting_z_height == 0: + print(f"{self.name}: Are you certain that the lid nests 0 mm with its parent plate?") + + def serialize(self) -> dict: + return {**super().serialize(), "nesting_z_height": self.nesting_z_height} -class Plate(ItemizedResource[Well]): +class Plate(ResourceHolderMixin, ItemizedResource[Well]): """ Base class for Plate resources. """ def __init__( @@ -43,45 +55,51 @@ def __init__( size_x: float, size_y: float, size_z: float, - items: Optional[List[List[Well]]] = None, - num_items_x: Optional[int] = None, - num_items_y: Optional[int] = None, + ordered_items: Optional[Dict[str, Well]] = None, + ordering: Optional[List[str]] = None, category: str = "plate", - lid_height: float = 0, - with_lid: bool = False, - model: Optional[str] = None + lid: Optional[Lid] = None, + model: Optional[str] = None, + plate_type: Literal["skirted", "semi-skirted", "non-skirted"] = "skirted", ): """ Initialize a Plate resource. Args: - name: Name of the plate. - size_x: Size of the plate in the x direction. - size_y: Size of the plate in the y direction. - size_z: Size of the plate in the z direction. - dx: The distance between the start of the plate and the center of the first well (A1) in the x - direction. - dy: The distance between the start of the plate and the center of the first well (A1) in the y - direction. - dz: The distance between the start of the plate and the center of the first well (A1) in the z - direction. - num_items_x: Number of wells in the x direction. - num_items_y: Number of wells in the y direction. well_size_x: Size of the wells in the x direction. well_size_y: Size of the wells in the y direction. - lid_height: Height of the lid in mm, only used if `with_lid` is True. - with_lid: Whether the plate has a lid. + lid: Immediately assign a lid to the plate. + plate_type: Type of the plate. One of "skirted", "semi-skirted", or "non-skirted". A + WIP: https://github.com/PyLabRobot/pylabrobot/pull/152#discussion_r1625831517 """ - super().__init__(name, size_x, size_y, size_z, items=items, num_items_x=num_items_x, - num_items_y=num_items_y, category=category, model=model) - self.lid: Optional[Lid] = None - self.lid_height = lid_height + super().__init__(name, size_x, size_y, size_z, ordered_items=ordered_items, ordering=ordering, + category=category, model=model) + self._lid: Optional[Lid] = None + self.plate_type = plate_type + + if lid is not None: + self.assign_child_resource(lid) - if with_lid: - assert lid_height > 0, "Lid height must be greater than 0 if with_lid == True." + @property + def lid(self) -> Optional[Lid]: + return self._lid - lid = Lid(name + "_lid", size_x=size_x, size_y=size_y, size_z=lid_height) + @lid.setter + def lid(self, lid: Optional[Lid]) -> None: + if lid is None: + self.unassign_child_resource(self._lid) + else: self.assign_child_resource(lid) + self._lid = lid + + def _get_lid_location(self, lid: Lid) -> Coordinate: + return Coordinate(0, 0, self.get_size_z() - lid.nesting_z_height) + + def get_default_child_location(self, resource: Resource) -> Coordinate: + child_location = super().get_default_child_location(resource) + if isinstance(resource, Lid): + child_location += self._get_lid_location(resource) + return child_location def assign_child_resource( self, @@ -92,16 +110,14 @@ def assign_child_resource( if isinstance(resource, Lid): if self.has_lid(): raise ValueError(f"Plate '{self.name}' already has a lid.") - self.lid = resource - assert self.lid_height > 0, "Lid height must be greater than 0." - location = Coordinate(0, 0, self.get_size_z() - self.lid_height) + self._lid = resource else: assert location is not None, "Location must be specified for if resource is not a lid." return super().assign_child_resource(resource, location=location, reassign=reassign) def unassign_child_resource(self, resource): - if isinstance(resource, Lid) and self.has_lid(): - self.lid = None + if isinstance(resource, Lid) and resource == self.lid: + self._lid = None return super().unassign_child_resource(resource) def __repr__(self) -> str: @@ -149,7 +165,7 @@ def set_well_liquids( Example: Set the volume of each well in a 96-well plate to 10 uL. - >>> plate = Plate("plate", 127.0, 86.0, 14.5, num_items_x=12, num_items_y=8) + >>> plate = Plate("plate", 127.76, 85.48, 14.5, num_items_x=12, num_items_y=8) >>> plate.set_well_liquids((Liquid.WATER, 10)) """ @@ -181,6 +197,8 @@ def enable_volume_trackers(self) -> None: for well in self.get_all_items(): well.tracker.enable() +# TODO: add quadrant definition for 96-well plates & specify current +# quadrant definition is only for 384-well plates def get_quadrant(self, quadrant: int) -> List[Well]: """ Return the wells in the specified quadrant. Quadrants are overlapping and refer to alternating rows and columns of the plate. Quadrant 1 contains A1, A3, C1, etc. Quadrant 2 diff --git a/pylabrobot/resources/plate_adapter.py b/pylabrobot/resources/plate_adapter.py index 345c0115f5..21e3dac1b6 100644 --- a/pylabrobot/resources/plate_adapter.py +++ b/pylabrobot/resources/plate_adapter.py @@ -84,7 +84,7 @@ def __init__( self.adapter_hole_size_x = adapter_hole_size_x self.adapter_hole_size_y = adapter_hole_size_y self.adapter_hole_size_z = adapter_hole_size_z - self.adapter_hole_size_z = self.get_size_z() - self.dz + self.adapter_hole_size_z = self.get_absolute_size_z() - self.dz self.adapter_hole_dx = adapter_hole_dx self.adapter_hole_dy = adapter_hole_dy self.plate_z_offset = plate_z_offset @@ -111,8 +111,8 @@ def calculate_well_spacing(float_list: List[float]): plate_dx, plate_dy = float(x_locations[0]), float(y_locations[0]) plate_item_dx = abs(calculate_well_spacing(x_locations)) plate_item_dy = abs(calculate_well_spacing(y_locations)) - well_size_x = resource.children[0].get_size_x() - well_size_y = resource.children[0].get_size_y() + well_size_x = resource.children[0].get_absolute_size_x() + well_size_y = resource.children[0].get_absolute_size_y() # true_dz = resource.get_size_z() - resource.children[0].get_size_z() # Well-grid to hole-grid compatibility check diff --git a/pylabrobot/resources/plate_tests.py b/pylabrobot/resources/plate_tests.py index b57e2a39ec..4c66a21ff1 100644 --- a/pylabrobot/resources/plate_tests.py +++ b/pylabrobot/resources/plate_tests.py @@ -3,33 +3,33 @@ import unittest from .coordinate import Coordinate -from .itemized_resource import create_equally_spaced from .plate import Plate, Lid +from .utils import create_ordered_items_2d from .well import Well class TestLid(unittest.TestCase): def test_initialize_with_lid(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=15, lid_height=10, items=[], - with_lid=True) + lid = Lid("plate_lid", size_x=1, size_y=1, size_z=10, nesting_z_height=10) + plate = Plate("plate", size_x=1, size_y=1, size_z=15, ordered_items={}, lid=lid) plate.location = Coordinate.zero() assert plate.lid is not None self.assertEqual(plate.lid.name, "plate_lid") - self.assertEqual(plate.lid.get_size_x(), 1) + self.assertEqual(plate.lid.get_absolute_size_x(), 1) self.assertEqual(plate.lid.get_absolute_location(), Coordinate(0, 0, 5)) def test_add_lid(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=10, items=[]) + plate = Plate("plate", size_x=1, size_y=1, size_z=1, ordered_items={}) lid = Lid(name="another_lid", size_x=plate.get_size_x(), size_y=plate.get_size_y(), - size_z=plate.get_size_z()) + size_z=plate.get_size_z(), nesting_z_height=plate.get_size_z()) plate.assign_child_resource(lid, location=Coordinate(0, 0, 0)) return plate def test_add_lid_with_existing_lid(self): plate = self.test_add_lid() another_lid = Lid(name="another_lid", size_x=plate.get_size_x(), size_y=plate.get_size_y(), - size_z=plate.get_size_z()) + size_z=plate.get_size_z(), nesting_z_height=plate.get_size_z()) with self.assertRaises(ValueError): plate.assign_child_resource(another_lid, location=Coordinate(0, 0, 0)) @@ -38,7 +38,7 @@ def test_add_lid_with_existing_lid(self): self.assertIsNone(plate.lid) def test_quadrant(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=1, items=create_equally_spaced(Well, + plate = Plate("plate", size_x=1, size_y=1, size_z=1, ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=16, dx=1, dy=1, dz=1, item_dx=1, item_dy=1, diff --git a/pylabrobot/resources/porvair/plates.py b/pylabrobot/resources/porvair/plates.py index 7904d272ad..7350db9665 100644 --- a/pylabrobot/resources/porvair/plates.py +++ b/pylabrobot/resources/porvair/plates.py @@ -2,16 +2,20 @@ # pylint: disable=invalid-name -from pylabrobot.resources.plate import Plate +from typing import Optional +from pylabrobot.resources.plate import Lid, Plate from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType -from pylabrobot.resources.itemized_resource import create_equally_spaced +from pylabrobot.resources.utils import create_ordered_items_2d -from pylabrobot.resources.volume_functions import calculate_liquid_volume_container_2segments_square_vbottom +from pylabrobot.resources.height_volume_functions import ( + calculate_liquid_height_in_container_2segments_square_vbottom, + calculate_liquid_volume_container_2segments_square_vbottom) +# # # # # # # # # # Porvair_6_reservoir_47ml_Vb # # # # # # # # # # -def _compute_volume_from_height_Porvair_6x47_Reservoir(h: float): +def _compute_volume_from_height_Porvair_6_reservoir_47ml_Vb(h: float): if h > 42.5: - raise ValueError(f"Height {h} is too large for Porvair_6x47_Reservoir") + raise ValueError(f"Height {h} is too large for Porvair_6_reservoir_47ml_Vb") return calculate_liquid_volume_container_2segments_square_vbottom( x=17, y=70.8, @@ -20,17 +24,49 @@ def _compute_volume_from_height_Porvair_6x47_Reservoir(h: float): liquid_height=h) -#: Porvair_6x47_Reservoir -def Porvair_6x47_Reservoir(name: str, with_lid: bool = False) -> Plate: +def _compute_height_from_volume_Porvair_6_reservoir_47ml_Vb(liquid_volume: float): + if liquid_volume > 49_350.0: # 5% tolerance + raise ValueError(f"Volume {liquid_volume} is too large for Porvair_6_reservoir_47ml_Vb") + return round(calculate_liquid_height_in_container_2segments_square_vbottom( + x=17, + y=70.8, + h_pyramid=5, + h_cube=37.5, + liquid_volume=liquid_volume),3) + + +def Porvair_6_reservoir_47ml_Vb_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.76, + # size_y=85.48, + # size_z=5, + # nesting_z_height=None, # measure overlap between lid and plate + # model="Porvair_6_reservoir_47ml_Vb_Lid", + # ) + + +#: Porvair_6_reservoir_47ml_Vb +def Porvair_6_reservoir_47ml_Vb(name: str, with_lid: bool = False) -> Plate: + """ Porvair cat. no.: 390015. + - Material: Polypropylene + - Sterilization compatibility: Autoclaving (15 minutes at 121°C) or Gamma Irradiation + - Chemical resistance: "High chemical resistance" + - Temperature resistance: high: -196°C to + 121°C + - Cleanliness: 390015: Free of detectable DNase, RNase + - ANSI/SLAS-format for compatibility with automated systems + - Tolerances: "Uniform external dimensions and tolerances" + """ return Plate( name=name, - size_x=127.0, - size_y=86.0, + size_x=127.76, + size_y=85.48, size_z=44, - with_lid=with_lid, - model="Porvair_6x47_Reservoir", - lid_height=5, - items=create_equally_spaced(Well, + lid=Porvair_6_reservoir_47ml_Vb_Lid(name + "_lid") if with_lid else None, + model="Porvair_6_reservoir_47ml_Vb", + ordered_items=create_ordered_items_2d(Well, num_items_x=6, num_items_y=1, dx=9.3, @@ -42,17 +78,70 @@ def Porvair_6x47_Reservoir(name: str, with_lid: bool = False) -> Plate: size_y=70.8, size_z=42.5, bottom_type=WellBottomType.V, - compute_volume_from_height=_compute_volume_from_height_Porvair_6x47_Reservoir, - cross_section_type=CrossSectionType.RECTANGLE + cross_section_type=CrossSectionType.RECTANGLE, + compute_volume_from_height=_compute_volume_from_height_Porvair_6_reservoir_47ml_Vb, + compute_height_from_volume=_compute_height_from_volume_Porvair_6_reservoir_47ml_Vb ), ) -#: Porvair_6x47_Reservoir_L -def Porvair_6x47_Reservoir_L(name: str, with_lid: bool = False) -> Plate: - return Porvair_6x47_Reservoir(name=name, with_lid=with_lid) +#: Porvair_6_reservoir_47ml_Vb_L +def Porvair_6_reservoir_47ml_Vb_L(name: str, with_lid: bool = False) -> Plate: + return Porvair_6_reservoir_47ml_Vb(name=name, with_lid=with_lid) + +#: Porvair_6_reservoir_47ml_Vb_P +def Porvair_6_reservoir_47ml_Vb_P(name: str, with_lid: bool = False) -> Plate: + return Porvair_6_reservoir_47ml_Vb(name=name, with_lid=with_lid).rotated(z=90) -#: Porvair_6x47_Reservoir_P -def Porvair_6x47_Reservoir_P(name: str, with_lid: bool = False) -> Plate: - return Porvair_6x47_Reservoir(name=name, with_lid=with_lid).rotated(90) + +def Porvair_24_wellplate_Vb(name: str, lid: Optional[Lid] = None) -> Plate: + """ + Porvair cat. no.: 390108 + - Material: Polypropylene + - Tissue culture treated: No + """ + WELL_SIZE_X = 8.0 + WELL_SIZE_Y = 35.0 + WELL_HEIGHT_OF_PYRAMID = 11.65 + WELL_HEIGHT_OF_CUBE = 1.65 + + well_kwargs = { + "size_x": 8.0, + "size_y": 35.0, + "size_z": 13.30, + # reality: multifaceted pyramid, v bottom pyramid is a good approximation + "bottom_type": WellBottomType.V, + "compute_volume_from_height": + lambda liquid_height: calculate_liquid_volume_container_2segments_square_vbottom( + WELL_SIZE_X, WELL_SIZE_Y, WELL_HEIGHT_OF_PYRAMID, WELL_HEIGHT_OF_CUBE, liquid_height + ), + "compute_height_from_volume": lambda liquid_volume: ( + calculate_liquid_height_in_container_2segments_square_vbottom( + WELL_SIZE_X, WELL_SIZE_Y, WELL_HEIGHT_OF_PYRAMID, WELL_HEIGHT_OF_CUBE, liquid_volume + ) + ), + "material_z_thickness": 1.0, # measured + "cross_section_type": CrossSectionType.RECTANGLE, + "max_volume": 3500, # according to porvair spec + } + + return Plate( + name=name, + size_x=127.5, # measured + size_y=85.3, # measured + size_z=19.2, # measured + lid=lid, + model=Porvair_24_wellplate_Vb.__name__, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=2, + dx=9.90, # measured + dy=6.85, # measured + dz=5.8, # measured and calibrated manually + item_dx=9.05, + item_dy=36, + **well_kwargs, + ), + ) diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index d594bd7af4..6f17f44c0f 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -1,15 +1,17 @@ from __future__ import annotations -import copy import itertools import json import logging import sys -from typing import Any, Callable, Dict, List, Optional, Type, cast +from typing import Any, Callable, Dict, List, Optional, cast from .coordinate import Coordinate from .errors import ResourceNotFoundError +from .rotation import Rotation from pylabrobot.serializer import serialize, deserialize +from pylabrobot.utils.linalg import matrix_vector_multiply_3x3 +from pylabrobot.utils.object_parsing import find_subclass if sys.version_info >= (3, 11): from typing import Self @@ -45,13 +47,16 @@ def __init__( size_x: float, size_y: float, size_z: float, + rotation: Optional[Rotation] = None, category: Optional[str] = None, - model: Optional[str] = None + model: Optional[str] = None, ): self._name = name self._size_x = size_x self._size_y = size_y self._size_z = size_z + self._local_size_z = size_z + self.rotation = rotation or Rotation() self.category = category self.model = model @@ -59,14 +64,24 @@ def __init__( self.parent: Optional[Resource] = None self.children: List[Resource] = [] - self.rotation = 0 - self._will_assign_resource_callbacks: List[WillAssignResourceCallback] = [] self._did_assign_resource_callbacks: List[DidAssignResourceCallback] = [] self._will_unassign_resource_callbacks: List[WillUnassignResourceCallback] = [] self._did_unassign_resource_callbacks: List[DidUnassignResourceCallback] = [] self._resource_state_updated_callbacks: List[ResourceDidUpdateState] = [] + def get_size_x(self) -> float: + """ Local size in the x direction. """ + return self._size_x + + def get_size_y(self) -> float: + """ Local size in the y direction. """ + return self._size_y + + def get_size_z(self) -> float: + """ Local size in the z direction. """ + return self._local_size_z + def serialize(self) -> dict: """ Serialize this resource. """ return { @@ -76,6 +91,7 @@ def serialize(self) -> dict: "size_y": self._size_y, "size_z": self._size_z, "location": serialize(self.location), + "rotation": serialize(self.rotation), "category": self.category, "model": self.model, "children": [child.serialize() for child in self.children], @@ -98,20 +114,13 @@ def name(self, name: str): raise RuntimeError("Cannot change the name of a resource that is assigned.") self._name = name - def copy(self): - """ Copy this resource. """ - if self.parent is not None: - raise ValueError("Cannot copy a resource that is assigned to another resource.") - - return copy.deepcopy(self) - def __eq__(self, other): return ( isinstance(other, Resource) and self.name == other.name and - self.get_size_x() == other.get_size_x() and - self.get_size_y() == other.get_size_y() and - self.get_size_z() == other.get_size_z() and + self.get_absolute_size_x() == other.get_absolute_size_x() and + self.get_absolute_size_y() == other.get_absolute_size_y() and + self.get_absolute_size_z() == other.get_absolute_size_z() and self.location == other.location and self.category == other.category and self.children == other.children @@ -125,27 +134,127 @@ def __repr__(self) -> str: def __hash__(self) -> int: return hash(repr(self)) - def get_absolute_location(self) -> Coordinate: - """ Get the absolute location of this resource, probably within the - :class:`pylabrobot.resources.Deck`. """ - assert self.location is not None, "Resource has no location." + def get_anchor(self, x: str, y: str, z: str) -> Coordinate: + """ Get a relative location within the resource. + + Args: + x: `"l"`/`"left"`, `"c"`/`"center"`, or `"r"`/`"right"` + y: `"b"`/`"back"`, `"c"`/`"center"`, or `"f"`/`"front"` + z: `"t"`/`"top"`, `"c"`/`"center"`, or `"b"`/`"bottom"` + + Returns: + A relative location within the resource, the anchor point wrt the left front bottom corner. + + Examples: + >>> r = Resource("resource", size_x=12, size_y=12, size_z=12) + >>> r.get_anchor("l", "b", "t") + + Coordinate(x=0.0, y=12.0, z=12.0) + + >>> r.get_anchor("c", "c", "c") + + Coordinate(x=6.0, y=6.0, z=6.0) + + >>> r.get_anchor("r", "f", "b") + + Coordinate(x=12.0, y=0.0, z=0.0) + """ + + x_: float + if x.lower() in {"l", "left"}: + x_ = 0 + elif x.lower() in {"c", "center"}: + x_ = self.get_size_x() / 2 + elif x.lower() in {"r", "right"}: + x_ = self.get_size_x() + else: + raise ValueError(f"Invalid x value: {x}") + + y_: float + if y.lower() in {"b", "back"}: + y_ = self.get_size_y() + elif y.lower() in {"c", "center"}: + y_ = self.get_size_y() / 2 + elif y.lower() in {"f", "front"}: + y_ = 0 + else: + raise ValueError(f"Invalid y value: {y}") + + z_: float + if z.lower() in {"t", "top"}: + z_ = self.get_size_z() + elif z.lower() in {"c", "center"}: + z_ = self.get_size_z() / 2 + elif z.lower() in {"b", "bottom"}: + z_ = 0 + else: + raise ValueError(f"Invalid z value: {z}") + + return Coordinate(x_, y_, z_) + + def get_absolute_rotation(self) -> Rotation: + """ Get the absolute rotation of this resource. """ if self.parent is None: - return self.location - return self.parent.get_absolute_location() + self.location + return self.rotation + return self.parent.get_absolute_rotation() + self.rotation - def get_size_x(self) -> float: - if self.rotation in {90, 270}: - return self._size_y - return self._size_x + def get_absolute_location(self, x: str = "l", y: str = "f", z: str = "b") -> Coordinate: + """ Get the absolute location of this resource, probably within the + :class:`pylabrobot.resources.Deck`. The `x`, `y`, and `z` arguments specify the anchor point + within the resource. The default is the left front bottom corner. - def get_size_y(self) -> float: - if self.rotation in {90, 270}: - return self._size_x - return self._size_y + Args: + x: `"l"`/`"left"`, `"c"`/`"center"`, or `"r"`/`"right"` + y: `"b"`/`"back"`, `"c"`/`"center"`, or `"f"`/`"front"` + z: `"t"`/`"top"`, `"c"`/`"center"`, or `"b"`/`"bottom"` + """ - def get_size_z(self) -> float: - """ Get the size of this resource in the z-direction. """ - return self._size_z + assert self.location is not None, "Resource has no location." + if self.parent is None: + return self.location + parent_pos = self.parent.get_absolute_location() + + rotated_location = Coordinate(*matrix_vector_multiply_3x3( + self.parent.get_absolute_rotation().get_rotation_matrix(), + self.location.vector() + )) + rotated_anchor = Coordinate(*matrix_vector_multiply_3x3( + self.get_absolute_rotation().get_rotation_matrix(), + self.get_anchor(x=x, y=y, z=z).vector() + )) + return parent_pos + rotated_location + rotated_anchor + + def _get_rotated_corners(self) -> List[Coordinate]: + absolute_rotation = self.get_absolute_rotation() + rot_mat = absolute_rotation.get_rotation_matrix() + return [ + Coordinate(*matrix_vector_multiply_3x3(rot_mat, corner.vector())) + for corner in [ + Coordinate(0, 0, 0), + Coordinate(self.get_size_x(), 0, 0), + Coordinate(0, self.get_size_y(), 0), + Coordinate(self.get_size_x(), self.get_size_y(), 0), + Coordinate(0, 0, self.get_size_z()), + Coordinate(self.get_size_x(), 0, self.get_size_z()), + Coordinate(0, self.get_size_y(), self.get_size_z()), + Coordinate(self.get_size_x(), self.get_size_y(), self.get_size_z()) + ] + ] + + def get_absolute_size_x(self) -> float: + """ Get the absolute size in the x direction. """ + rotated_corners = self._get_rotated_corners() + return max(c.x for c in rotated_corners) - min(c.x for c in rotated_corners) + + def get_absolute_size_y(self) -> float: + """ Get the absolute size in the y direction. """ + rotated_corners = self._get_rotated_corners() + return max(c.y for c in rotated_corners) - min(c.y for c in rotated_corners) + + def get_absolute_size_z(self) -> float: + """ Get the absolute size in the z direction. """ + rotated_corners = self._get_rotated_corners() + return max(c.z for c in rotated_corners) - min(c.z for c in rotated_corners) def assign_child_resource( self, @@ -300,45 +409,23 @@ def get_resource(self, name: str) -> Resource: raise ResourceNotFoundError(f"Resource with name '{name}' does not exist.") - def rotate(self, degrees: int): - """ Rotate counter clockwise by the given number of degrees. - - Args: - degrees: must be a multiple of 90, but not also 360. - """ - - effective_degrees = degrees % 360 - - if effective_degrees == 0 or effective_degrees % 90 != 0: - raise ValueError(f"Invalid rotation: {degrees}") - - for child in self.children: - assert child.location is not None, "child must have a location when it's assigned." + def rotate(self, x: float = 0, y: float = 0, z: float = 0): + """ Rotate counter-clockwise by the given number of degrees. """ - old_x = child.location.x + self.rotation.x = (self.rotation.x + x) % 360 + self.rotation.y = (self.rotation.y + y) % 360 + self.rotation.z = (self.rotation.z + z) % 360 - if effective_degrees == 90: - child.location.x = self.get_size_y() - child.location.y - child.get_size_y() - child.location.y = old_x - elif effective_degrees == 180: - child.location.x = self.get_size_x() - child.location.x - child.get_size_x() - child.location.y = self.get_size_y() - child.location.y - child.get_size_y() - elif effective_degrees == 270: - child.location.x = child.location.y - child.location.y = self.get_size_x() - old_x - child.get_size_x() - child.rotate(effective_degrees) + def copy(self) -> Self: + resource_copy = self.__class__.deserialize(self.serialize(), allow_marshal=True) + resource_copy.load_all_state(self.serialize_all_state()) + return resource_copy - self.rotation = (self.rotation + degrees) % 360 + def rotated(self, x: float = 0, y: float = 0, z: float = 0) -> Self: + """ Return a copy of this resource rotated by the given number of degrees. """ - def rotated(self, degrees: int) -> Self: # type: ignore - """ Return a copy of this resource rotated by the given number of degrees. - - Args: - degrees: must be a multiple of 90, but not also 360. - """ - - new_resource = copy.deepcopy(self) - new_resource.rotate(degrees) + new_resource = self.copy() + new_resource.rotate(x=x, y=y, z=z) return new_resource def center(self, x: bool = True, y: bool = True, z: bool = False) -> Coordinate: @@ -445,9 +532,13 @@ def save(self, fn: str, indent: Optional[int] = None): json.dump(serialized, f, indent=indent) @classmethod - def deserialize(cls, data: dict) -> Self: + def deserialize(cls, data: dict, allow_marshal: bool = False) -> Self: """ Deserialize a resource from a dictionary. + Args: + allow_marshal: If `True`, the `marshal` module will be used to deserialize functions. This + can be a security risk if the data is not trusted. Defaults to `False`. + Examples: Loading a resource from a json file: @@ -459,21 +550,23 @@ def deserialize(cls, data: dict) -> Self: data_copy = data.copy() # copy data because we will be modifying it - subclass = get_resource_class_from_string(data["type"]) + subclass = find_subclass(data["type"], cls=Resource) if subclass is None: - raise ValueError(f"Could not find subclass with name '{data['type']}'") + raise ValueError(f'Could not find subclass with name "{data["type"]}"') assert issubclass(subclass, cls) # mypy does not know the type after the None check... for key in ["type", "parent_name", "location"]: # delete meta keys del data_copy[key] children_data = data_copy.pop("children") - resource = subclass(**data_copy) + rotation = data_copy.pop("rotation") + resource = subclass(**deserialize(data_copy, allow_marshal=allow_marshal)) + resource.rotation = Rotation.deserialize(rotation) # not pretty, should be done in init. for child_data in children_data: - child_cls = get_resource_class_from_string(child_data["type"]) + child_cls = find_subclass(child_data["type"], cls=Resource) if child_cls is None: - raise ValueError(f"Could not find subclass with name {child_data['type']}") - child = child_cls.deserialize(child_data) + raise ValueError(f'Could not find subclass with name {child_data["type"]}') + child = child_cls.deserialize(child_data, allow_marshal=allow_marshal) location_data = child_data.get("location", None) if location_data is not None: location = cast(Coordinate, deserialize(location_data)) @@ -504,43 +597,23 @@ def load_from_json_file(cls, json_file: str) -> Self: # type: ignore def register_will_assign_resource_callback(self, callback: WillAssignResourceCallback): """ Add a callback that will be called before a resource is assigned to this resource. These - callbacks can raise errors in case the proposed assignment is invalid. - - Args: - callback: The callback to add. - """ + callbacks can raise errors in case the proposed assignment is invalid. """ self._will_assign_resource_callbacks.append(callback) def register_did_assign_resource_callback(self, callback: DidAssignResourceCallback): - """ Add a callback that will be called after a resource is assigned to this resource. - - Args: - callback: The callback to add. - """ + """ Add a callback that will be called after a resource is assigned to this resource. """ self._did_assign_resource_callbacks.append(callback) def register_will_unassign_resource_callback(self, callback: WillUnassignResourceCallback): - """ Add a callback that will be called before a resource is unassigned from this resource. - - Args: - callback: The callback to add. - """ + """ Add a callback that will be called before a resource is unassigned from this resource. """ self._will_unassign_resource_callbacks.append(callback) def register_did_unassign_resource_callback(self, callback: DidUnassignResourceCallback): - """ Add a callback that will be called after a resource is unassigned from this resource. - - Args: - callback: The callback to add. - """ + """ Add a callback that will be called after a resource is unassigned from this resource. """ self._did_unassign_resource_callbacks.append(callback) def deregister_will_assign_resource_callback(self, callback: WillAssignResourceCallback): - """ Remove a callback that will be called before a resource is assigned to this resource. - - Args: - callback: The callback to remove. - """ + """ Remove a callback that will be called before a resource is assigned to this resource. """ self._will_assign_resource_callbacks.remove(callback) def deregister_did_assign_resource_callback(self, callback: DidAssignResourceCallback): @@ -642,26 +715,3 @@ def deregister_state_update_callback(self, callback: ResourceDidUpdateState): def _state_updated(self): for callback in self._resource_state_updated_callbacks: callback(self.serialize_state()) - - -def get_resource_class_from_string( - class_name: str, - cls: Type[Resource] = Resource -) -> Optional[Type[Resource]]: - """ Recursively find a subclass with the correct name. - - Args: - class_name: The name of the class to find. - cls: The class to search in. - - Returns: - The class with the given name, or `None` if no such class exists. - """ - - if cls.__name__ == class_name: - return cls - for subclass in cls.__subclasses__(): - subclass_ = get_resource_class_from_string(class_name=class_name, cls=subclass) - if subclass_ is not None: - return subclass_ - return None diff --git a/pylabrobot/resources/resource_holder.py b/pylabrobot/resources/resource_holder.py new file mode 100644 index 0000000000..1a12de3e19 --- /dev/null +++ b/pylabrobot/resources/resource_holder.py @@ -0,0 +1,41 @@ +from typing import Optional +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource + +def get_child_location(resource: Resource) -> Coordinate: + """ + If a resource is rotated, its rotated around its local origin. This does not always + match up with the parent resource's origin. This function calculates the difference + between the two origins and calculates the location of the resource correctly. + """ + if not resource.rotation.y == resource.rotation.x == 0: + raise ValueError("Resource rotation must be 0 around the x and y axes") + if not resource.rotation.z % 90 == 0: + raise ValueError("Resource rotation must be a multiple of 90 degrees on the z axis") + location = { + 0.0: Coordinate(x=0, y=0, z=0), + 90.0: Coordinate(x=resource.get_size_y(), y=0, z=0), + 180.0: Coordinate(x=resource.get_size_x(), y=resource.get_size_y(), z=0), + 270.0: Coordinate(x=0, y=resource.get_size_x(), z=0), + }[resource.rotation.z % 360] + return location + +class ResourceHolderMixin: + """ + A mixin class for resources that can hold other resources, like a plate or a lid. + + This applies a linear transformation after the rotation to correctly place the child resource. + """ + + def get_default_child_location(self, resource: Resource) -> Coordinate: + return get_child_location(resource) + + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True + ): + location = location or self.get_default_child_location(resource) + # mypy doesn't play well with the Mixin pattern + return super().assign_child_resource(resource, location, reassign) # type: ignore diff --git a/pylabrobot/resources/resource_stack.py b/pylabrobot/resources/resource_stack.py index c703203135..1642158a30 100644 --- a/pylabrobot/resources/resource_stack.py +++ b/pylabrobot/resources/resource_stack.py @@ -1,10 +1,14 @@ +import logging from typing import List, Optional +from pylabrobot.resources.resource_holder import ResourceHolderMixin from pylabrobot.resources.resource import Resource from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.plate import Plate +logger = logging.getLogger("pylabrobot") -class ResourceStack(Resource): +class ResourceStack(ResourceHolderMixin, Resource): """ ResourceStack represent a group of resources that are stacked together and act as a single unit. Stacks can grow be configured to be able to grow in x, y, or z direction. Stacks growing in the x direction are from left to right. Stacks growing in the y direction are from front to @@ -63,6 +67,7 @@ def __str__(self) -> str: return f"ResourceGroup({self.name})" def get_size_x(self) -> float: + """ Get local size in the x direction. """ if len(self.children) == 0: return 0 if self.direction == "x": @@ -70,6 +75,7 @@ def get_size_x(self) -> float: return max(resource.get_size_x() for resource in self.children) def get_size_y(self) -> float: + """ Get local size in the y direction. """ if len(self.children) == 0: return 0 if self.direction == "y": @@ -77,22 +83,36 @@ def get_size_y(self) -> float: return max(resource.get_size_y() for resource in self.children) def get_size_z(self) -> float: + """ Get local size in the z direction. """ + def get_actual_resource_height(resource: Resource) -> float: + """ Helper function to get the actual height of a resource, accounting for the lid nesting + height if the resource is a plate with a lid. """ + if isinstance(resource, Plate) and resource.lid is not None: + return resource.get_size_z() + resource.lid.get_size_z() - resource.lid.nesting_z_height + return resource.get_size_z() + if len(self.children) == 0: return 0 - if self.direction == "z": - return sum(child.get_size_z() for child in self.children) - return max(child.get_size_z() for child in self.children) - def assign_child_resource(self, resource): - # update child location (relative to self): we place the child after the last child in the stack + if self.direction != "z": + return max(get_actual_resource_height(child) for child in self.children) + return sum(get_actual_resource_height(child) for child in self.children) + + + def get_resource_stack_edge(self) -> Coordinate: if self.direction == "x": resource_location = Coordinate(self.get_size_x(), 0, 0) elif self.direction == "y": resource_location = Coordinate(0, self.get_size_y(), 0) elif self.direction == "z": resource_location = Coordinate(0, 0, self.get_size_z()) + else: + raise ValueError("self.direction must be one of 'x', 'y', or 'z'") + + return resource_location - super().assign_child_resource(resource, location=resource_location) + def get_default_child_location(self, resource: Resource) -> Coordinate: + return super().get_default_child_location(resource) + self.get_resource_stack_edge() def unassign_child_resource(self, resource: Resource): if self.direction == "z" and resource != self.children[-1]: # no floating resources diff --git a/pylabrobot/resources/resource_stack_tests.py b/pylabrobot/resources/resource_stack_tests.py index 51b8c6acca..47dfffa6ac 100644 --- a/pylabrobot/resources/resource_stack_tests.py +++ b/pylabrobot/resources/resource_stack_tests.py @@ -3,7 +3,7 @@ import unittest -from pylabrobot.resources import Coordinate, Plate, Resource +from pylabrobot.resources import Coordinate, Lid, Plate, Resource from .resource_stack import ResourceStack @@ -23,9 +23,9 @@ def test_create_x(self): self.assertEqual(stack.get_resource("A").location, Coordinate(0, 0, 0)) self.assertEqual(stack.get_resource("B").location, Coordinate(10, 0, 0)) - self.assertEqual(stack.get_size_x(), 20) - self.assertEqual(stack.get_size_y(), 10) - self.assertEqual(stack.get_size_z(), 10) + self.assertEqual(stack.get_absolute_size_x(), 20) + self.assertEqual(stack.get_absolute_size_y(), 10) + self.assertEqual(stack.get_absolute_size_z(), 10) def test_create_y(self): stack = ResourceStack("stack", "y", [ @@ -35,9 +35,9 @@ def test_create_y(self): self.assertEqual(stack.get_resource("A").location, Coordinate(0, 0, 0)) self.assertEqual(stack.get_resource("B").location, Coordinate(0, 10, 0)) - self.assertEqual(stack.get_size_x(), 10) - self.assertEqual(stack.get_size_y(), 20) - self.assertEqual(stack.get_size_z(), 10) + self.assertEqual(stack.get_absolute_size_x(), 10) + self.assertEqual(stack.get_absolute_size_y(), 20) + self.assertEqual(stack.get_absolute_size_z(), 10) def test_create_z(self): stack = ResourceStack("stack", "z", [ @@ -47,53 +47,50 @@ def test_create_z(self): self.assertEqual(stack.get_resource("A").location, Coordinate(0, 0, 10)) self.assertEqual(stack.get_resource("B").location, Coordinate(0, 0, 0)) - self.assertEqual(stack.get_size_x(), 10) - self.assertEqual(stack.get_size_y(), 10) - self.assertEqual(stack.get_size_z(), 20) + self.assertEqual(stack.get_absolute_size_x(), 10) + self.assertEqual(stack.get_absolute_size_y(), 10) + self.assertEqual(stack.get_absolute_size_z(), 20) def test_get_size_empty_stack(self): stack = ResourceStack("stack", "z") - self.assertEqual(stack.get_size_x(), 0) - self.assertEqual(stack.get_size_y(), 0) - self.assertEqual(stack.get_size_z(), 0) + self.assertEqual(stack.get_absolute_size_x(), 0) + self.assertEqual(stack.get_absolute_size_y(), 0) + self.assertEqual(stack.get_absolute_size_z(), 0) # Tests for using ResourceStack as a stacking area, like the one near the washer on the STARs. def test_add_item(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=1, items=[]) + plate = Plate("plate", size_x=1, size_y=1, size_z=1, ordered_items={}) stacking_area = ResourceStack("stacking_area", "z") stacking_area.assign_child_resource(plate) self.assertEqual(stacking_area.get_top_item(), plate) def test_get_absolute_location_plate(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=1, items=[]) + plate = Plate("plate", size_x=1, size_y=1, size_z=1, ordered_items={}) stacking_area = ResourceStack("stacking_area", "z") stacking_area.location = Coordinate.zero() stacking_area.assign_child_resource(plate) self.assertEqual(plate.get_absolute_location(), Coordinate(0, 0, 0)) def test_get_absolute_location_lid(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=1, items=[], - with_lid=True) + lid = Lid(name="lid", size_x=1, size_y=1, size_z=1, nesting_z_height=0) stacking_area = ResourceStack("stacking_area", "z") stacking_area.location = Coordinate.zero() - stacking_area.assign_child_resource(plate.lid) + stacking_area.assign_child_resource(lid) self.assertEqual(stacking_area.get_top_item().get_absolute_location(), Coordinate(0, 0, 0)) def test_get_absolute_location_stack_height(self): - plate = Plate("plate", size_x=1, size_y=1, size_z=1, lid_height=1, items=[], - with_lid=True) - plate2 = Plate("plate2", size_x=1, size_y=1, size_z=1, lid_height=1, items=[], - with_lid=True) + lid = Lid(name="lid", size_x=1, size_y=1, size_z=1, nesting_z_height=0) + lid2 = Lid(name="lid2", size_x=1, size_y=1, size_z=1, nesting_z_height=0) stacking_area = ResourceStack("stacking_area", "z") stacking_area.location = Coordinate.zero() - stacking_area.assign_child_resource(plate.lid) + stacking_area.assign_child_resource(lid) top_item = stacking_area.get_top_item() assert top_item is not None self.assertEqual(top_item.get_absolute_location(), Coordinate(0, 0, 0)) - stacking_area.assign_child_resource(plate2.lid) + stacking_area.assign_child_resource(lid2) top_item = stacking_area.get_top_item() assert top_item is not None self.assertEqual(top_item.get_absolute_location(), Coordinate(0, 0, 1)) diff --git a/pylabrobot/resources/resource_tests.py b/pylabrobot/resources/resource_tests.py index cbb0b4cc50..c23dfca2a7 100644 --- a/pylabrobot/resources/resource_tests.py +++ b/pylabrobot/resources/resource_tests.py @@ -1,6 +1,7 @@ """ Tests for Resource """ # pylint: disable=missing-class-docstring +import math import unittest import unittest.mock @@ -8,9 +9,34 @@ from .deck import Deck from .errors import ResourceNotFoundError from .resource import Resource +from .rotation import Rotation class TestResource(unittest.TestCase): + def test_simple_get_size(self): + r = Resource("test", size_x=10, size_y=10, size_z=10) + self.assertEqual(r.get_absolute_size_x(), 10) + self.assertEqual(r.get_absolute_size_y(), 10) + self.assertEqual(r.get_absolute_size_z(), 10) + + def test_rotated_45(self): + r = Resource("test", size_x=20, size_y=10, size_z=10) + r.rotation = Rotation(z=45) + width1 = 20 * math.cos(math.radians(45)) + 10 * math.cos(math.radians(45)) + self.assertAlmostEqual(r.get_absolute_size_x(), width1, places=5) + + height1 = 20 * math.sin(math.radians(45)) + 10 * math.sin(math.radians(45)) + self.assertAlmostEqual(r.get_absolute_size_y(), height1, places=5) + + def test_rotated_m45(self): + r = Resource("test", size_x=20, size_y=10, size_z=10) + r.rotation = Rotation(z=-45) + width1 = 20 * math.cos(math.radians(45)) + 10 * math.cos(math.radians(45)) + self.assertAlmostEqual(r.get_absolute_size_x(), width1, places=5) + + height1 = 20 * math.sin(math.radians(45)) + 10 * math.sin(math.radians(45)) + self.assertAlmostEqual(r.get_absolute_size_y(), height1, places=5) + def test_get_resource(self): deck = Deck() parent = Resource("parent", size_x=10, size_y=10, size_z=10) @@ -62,6 +88,16 @@ def test_assign_name_taken(self): other_child = Resource("child", size_x=5, size_y=5, size_z=5) deck.assign_child_resource(other_child, location=Coordinate(5, 5, 5)) + def test_get_anchor(self): + resource = Resource("test", size_x=12, size_y=12, size_z=12) + self.assertEqual(resource.get_anchor(x="left", y="back", z="bottom"), Coordinate(0, 12, 0)) + self.assertEqual(resource.get_anchor(x="right", y="front", z="top"), Coordinate(12, 0, 12)) + self.assertEqual(resource.get_anchor(x="center", y="center", z="center"), Coordinate(6, 6, 6)) + + self.assertEqual(resource.get_anchor(x="l", y="b", z="b"), Coordinate(0, 12, 0)) + self.assertEqual(resource.get_anchor(x="r", y="f", z="t"), Coordinate(12, 0, 12)) + self.assertEqual(resource.get_anchor(x="c", y="c", z="c"), Coordinate(6, 6, 6)) + def test_absolute_location(self): deck = Deck() parent = Resource("parent", size_x=10, size_y=10, size_z=10) @@ -72,6 +108,18 @@ def test_absolute_location(self): self.assertEqual(deck.get_resource("parent").get_absolute_location(), Coordinate(10, 10, 10)) self.assertEqual(deck.get_resource("child").get_absolute_location(), Coordinate(15, 15, 15)) + def test_get_absolute_location_with_anchor(self): + deck = Deck() + parent = Resource("parent", size_x=10, size_y=10, size_z=10) + deck.assign_child_resource(parent, location=Coordinate(10, 10, 10)) + child = Resource("child", size_x=5, size_y=5, size_z=5) + parent.assign_child_resource(child, location=Coordinate(5, 5, 5)) + + self.assertEqual(deck.get_resource("parent")\ + .get_absolute_location(x="right", y="front", z="top"), Coordinate(20, 10, 20)) + self.assertEqual(deck.get_resource("child")\ + .get_absolute_location(x="right", y="front", z="top"), Coordinate(20, 15, 20)) + def test_unassign_child(self): deck = Deck() parent = Resource("parent", size_x=10, size_y=10, size_z=10) @@ -113,6 +161,10 @@ def test_serialize(self): self.assertEqual(r.serialize(), { "name": "test", "location": None, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0, + }, "size_x": 10, "size_y": 10, "size_z": 10, @@ -131,6 +183,10 @@ def test_child_serialize(self): self.assertEqual(r.serialize(), { "name": "test", "location": None, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0, + }, "size_x": 10, "size_y": 10, "size_z": 10, @@ -142,6 +198,10 @@ def test_child_serialize(self): "type": "Coordinate", "x": 5, "y": 5, "z": 5, }, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0, + }, "size_x": 1, "size_y": 1, "size_z": 1, @@ -182,12 +242,12 @@ def test_rotation90(self): c = Resource("child", size_x=10, size_y=20, size_z=10) r.assign_child_resource(c, location=Coordinate(20, 10, 10)) - r.rotate(90) - self.assertEqual(r.get_size_x(), 100) - self.assertEqual(r.get_size_y(), 200) - self.assertEqual(c.get_absolute_location(), Coordinate(70, 20, 10)) - self.assertEqual(c.get_size_x(), 20) - self.assertEqual(c.get_size_y(), 10) + r.rotate(z=90) + self.assertAlmostEqual(r.get_absolute_size_x(), 100) + self.assertAlmostEqual(r.get_absolute_size_y(), 200) + self.assertEqual(c.get_absolute_location(), Coordinate(-10, 20, 10)) + self.assertAlmostEqual(c.get_absolute_size_x(), 20) + self.assertAlmostEqual(c.get_absolute_size_y(), 10) def test_rotation180(self): r = Resource("parent", size_x=200, size_y=100, size_z=100) @@ -195,12 +255,12 @@ def test_rotation180(self): c = Resource("child", size_x=10, size_y=20, size_z=10) r.assign_child_resource(c, location=Coordinate(20, 10, 10)) - r.rotate(180) - self.assertEqual(r.get_size_x(), 200) - self.assertEqual(r.get_size_y(), 100) - self.assertEqual(c.get_absolute_location(), Coordinate(170, 70, 10)) - self.assertEqual(c.get_size_x(), 10) - self.assertEqual(c.get_size_y(), 20) + r.rotate(z=180) + self.assertAlmostEqual(r.get_absolute_size_x(), 200) + self.assertAlmostEqual(r.get_absolute_size_y(), 100) + self.assertEqual(c.get_absolute_location(), Coordinate(x=-20, y=-10, z=10)) + self.assertAlmostEqual(c.get_absolute_size_x(), 10) + self.assertAlmostEqual(c.get_absolute_size_y(), 20) def test_rotation270(self): r = Resource("parent", size_x=200, size_y=100, size_z=100) @@ -208,24 +268,12 @@ def test_rotation270(self): c = Resource("child", size_x=10, size_y=20, size_z=10) r.assign_child_resource(c, location=Coordinate(20, 10, 10)) - r.rotate(270) - self.assertEqual(r.get_size_x(), 100) - self.assertEqual(r.get_size_y(), 200) - self.assertEqual(c.get_absolute_location(), Coordinate(10, 170, 10)) - self.assertEqual(c.get_size_x(), 20) - self.assertEqual(c.get_size_y(), 10) - - def test_rotation_invalid(self): - r = Resource("parent", size_x=200, size_y=100, size_z=100) - - with self.assertRaises(ValueError): - r.rotate(45) - - with self.assertRaises(ValueError): - r.rotate(360) - - with self.assertRaises(ValueError): - r.rotate(0) + r.rotate(z=270) + self.assertAlmostEqual(r.get_absolute_size_x(), 100) + self.assertAlmostEqual(r.get_absolute_size_y(), 200) + self.assertEqual(c.get_absolute_location(), Coordinate(x=10, y=-20, z=10)) + self.assertAlmostEqual(c.get_absolute_size_x(), 20) + self.assertAlmostEqual(c.get_absolute_size_y(), 10) def test_multiple_rotations(self): r = Resource("parent", size_x=200, size_y=100, size_z=100) @@ -233,20 +281,20 @@ def test_multiple_rotations(self): c = Resource("child", size_x=10, size_y=20, size_z=10) r.assign_child_resource(c, location=Coordinate(20, 10, 10)) - r.rotate(90) - r.rotate(90) # 180 - self.assertEqual(r.get_size_x(), 200) - self.assertEqual(r.get_size_y(), 100) - self.assertEqual(c.get_absolute_location(), Coordinate(170, 70, 10)) + r.rotate(z=90) + r.rotate(z=90) # 180 + self.assertAlmostEqual(r.get_absolute_size_x(), 200) + self.assertAlmostEqual(r.get_absolute_size_y(), 100) + self.assertEqual(c.get_absolute_location(), Coordinate(x=-20, y=-10, z=10)) - r.rotate(90) # 270 - self.assertEqual(r.get_size_x(), 100) - self.assertEqual(r.get_size_y(), 200) - self.assertEqual(c.get_absolute_location(), Coordinate(10, 170, 10)) + r.rotate(z=90) # 270 + self.assertAlmostEqual(r.get_absolute_size_x(), 100) + self.assertAlmostEqual(r.get_absolute_size_y(), 200) + self.assertEqual(c.get_absolute_location(), Coordinate(x=10, y=-20, z=10)) - r.rotate(90) # 0 - self.assertEqual(r.get_size_x(), 200) - self.assertEqual(r.get_size_y(), 100) + r.rotate(z=90) # 0 + self.assertAlmostEqual(r.get_absolute_size_x(), 200) + self.assertAlmostEqual(r.get_absolute_size_y(), 100) self.assertEqual(c.get_absolute_location(), Coordinate(20, 10, 10)) class TestResourceCallback(unittest.TestCase): diff --git a/pylabrobot/resources/revvity/plates.py b/pylabrobot/resources/revvity/plates.py index 939a90bb43..9b9e2491ae 100644 --- a/pylabrobot/resources/revvity/plates.py +++ b/pylabrobot/resources/revvity/plates.py @@ -2,19 +2,21 @@ # pylint: disable=invalid-name -from pylabrobot.resources.plate import Plate -from pylabrobot.resources.well import Well, WellBottomType -from pylabrobot.resources.itemized_resource import create_equally_spaced +from pylabrobot.resources.plate import Lid, Plate +from pylabrobot.resources.well import Well, WellBottomType, CrossSectionType +from pylabrobot.resources.utils import create_ordered_items_2d -from pylabrobot.resources.volume_functions import calculate_liquid_volume_container_2segments_round_vbottom +from pylabrobot.resources.height_volume_functions import calculate_liquid_volume_container_2segments_round_vbottom -def _compute_volume_from_height_Revvity_ProxiPlate_384Plus(h: float): - """ Simplification: instead of 3 segment (hemisphere-frustum of cone-cylinder) +# # # # # # # # # # Revvity_384_wellplate_28ul_Ub_Lid # # # # # # # # # # + +def _compute_volume_from_height_Revvity_384_wellplate_28ul_Ub(h: float): + """ Simplification: instead of 3 segment (hemisphere-frustum of cone-cylinder) -> 2 segment (cone-cylinder) """ if h > 77: - raise ValueError(f"Height {h} is too large for Revvity_ProxiPlate_384Plus") + raise ValueError(f"Height {h} is too large for Revvity_384_wellplate_28ul_Ub") return calculate_liquid_volume_container_2segments_round_vbottom( d=3.3, h_cone = 3, @@ -23,29 +25,55 @@ def _compute_volume_from_height_Revvity_ProxiPlate_384Plus(h: float): ) -#: Revvity_ProxiPlate_384Plus -def Revvity_ProxiPlate_384Plus(name: str, with_lid: bool = False) -> Plate: - # https://www.perkinelmer.com/uk/Product/proxiplate-384-plus-50w-6008280 +def Revvity_384_wellplate_28ul_Ub_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.76, + # size_y=85.48, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Revvity_384_wellplate_28ul_Ub_Lid", + # ) + + +#: Revvity_384_wellplate_28ul_Ub +def Revvity_384_wellplate_28ul_Ub(name: str, with_lid: bool = False) -> Plate: + """ Revvity cat. no.: 6008280. nickname "ProxiPlate-384 Plus" + - Material: Polystyrene + - Colour: white + - "shallow-well" + - Sterilization compatibility: ? + - Chemical resistance:? + - Thermal resistance: ? + - Surface treatment: non-treated + - Sealing options: ? + - Cleanliness: non-sterile + - Automation compatibility: not specifically declared + - Total volume = 28 ul + """ return Plate( name=name, - size_x=127.0, - size_y=86.0, + size_x=127.76, + size_y=85.48, size_z=14.35, - with_lid=with_lid, - model="Revvity_ProxiPlate_384Plus", - lid_height=10, - items=create_equally_spaced(Well, + lid=Revvity_384_wellplate_28ul_Ub_Lid(name + "_lid") if with_lid else None, + model="Revvity_384_wellplate_28ul_Ub", + ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=16, - dx=10.45, - dy=7.9, - dz=3.3, + dx=8.83, + dy=5.69, + dz=8.2, item_dx=4.5, item_dy=4.5, size_x=3.3, size_y=3.3, size_z=5.3, bottom_type=WellBottomType.U, - compute_volume_from_height=_compute_volume_from_height_Revvity_ProxiPlate_384Plus, + material_z_thickness=1.0, + cross_section_type=CrossSectionType.CIRCLE, + compute_volume_from_height=_compute_volume_from_height_Revvity_384_wellplate_28ul_Ub, ), ) diff --git a/pylabrobot/resources/rotation.py b/pylabrobot/resources/rotation.py index 1acb712fc8..c4f8959d85 100644 --- a/pylabrobot/resources/rotation.py +++ b/pylabrobot/resources/rotation.py @@ -41,3 +41,6 @@ def __add__(self, other) -> "Rotation": @staticmethod def deserialize(data) -> "Rotation": return Rotation(data["x"], data["y"], data["z"]) + + def __repr__(self) -> str: + return self.__str__() diff --git a/pylabrobot/resources/tecan/plate_carriers.py b/pylabrobot/resources/tecan/plate_carriers.py index e30c9846ef..7d20e62b19 100644 --- a/pylabrobot/resources/tecan/plate_carriers.py +++ b/pylabrobot/resources/tecan/plate_carriers.py @@ -7,7 +7,7 @@ from typing import List, Optional from pylabrobot.resources.carrier import ( PlateCarrier, - CarrierSite, + PlateCarrierSite, Coordinate, create_homogeneous_carrier_sites ) @@ -30,7 +30,7 @@ def __init__( roma_z_safe: Optional[float] = None, roma_z_travel: Optional[float] = None, roma_z_end: Optional[float] = None, - sites: Optional[List[CarrierSite]] = None, + sites: Optional[List[PlateCarrierSite]] = None, category="tecan_plate_carrier", model: Optional[str] = None): super().__init__(name, size_x, size_y, size_z, @@ -54,7 +54,7 @@ def MP_2Pos_portrait_No_Robot_Access(name: str) -> TecanPlateCarrier: size_z=62.5, off_x=12.0, off_y=24.7, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(47.5, 8.8, 62.5), Coordinate(47.5, 172.3, 62.5), ], @@ -74,7 +74,7 @@ def MP_2_Pos_portrait(name: str) -> TecanPlateCarrier: size_z=62.5, off_x=12.0, off_y=24.7, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(47.5, 34.3, 62.5), Coordinate(47.5, 172.3, 62.5), ], @@ -99,13 +99,14 @@ def MP_3Pos_PCR(name: str) -> TecanPlateCarrier: roma_z_safe=946, roma_z_travel=1938, roma_z_end=2566, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(5.5, 13.5, 62.5), Coordinate(5.5, 109.5, 62.5), Coordinate(5.5, 205.5, 62.5), ], site_size_x=127.0, site_size_y=85.5, + pedestal_size_z=0 # ? ), model="MP_3Pos_PCR" ) @@ -125,7 +126,7 @@ def MP_3Pos_TePS(name: str) -> TecanPlateCarrier: roma_z_safe=780, roma_z_travel=2012, roma_z_end=2543, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(7.6, 38.0, 84.0), Coordinate(7.6, 151.5, 84.0), Coordinate(7.6, 265.0, 84.0), @@ -151,7 +152,7 @@ def LI___MP_3Pos(name: str) -> TecanPlateCarrier: roma_z_safe=946, roma_z_travel=1938, roma_z_end=2537, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(5.5, 13.5, 62.5), Coordinate(5.5, 109.5, 62.5), Coordinate(5.5, 205.5, 62.5), @@ -172,7 +173,7 @@ def MP_4Pos_landscape(name: str) -> TecanPlateCarrier: size_z=83.0, off_x=7.5, off_y=70.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(10.2, 44.5, 83.0), Coordinate(10.2, 136.0, 83.0), Coordinate(10.2, 227.5, 83.0), @@ -194,7 +195,7 @@ def MP_12Pos_landscape(name: str) -> TecanPlateCarrier: size_z=32.0, off_x=11.5, off_y=35.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(280.4, 16.8, 32.0), Coordinate(280.4, 113.7, 32.0), Coordinate(280.4, 209.9, 32.0), @@ -224,7 +225,7 @@ def MP_8Pos_landscape(name: str) -> TecanPlateCarrier: size_z=32.0, off_x=11.5, off_y=35.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(141.4, 16.8, 32.0), Coordinate(141.4, 113.7, 32.0), Coordinate(141.4, 209.9, 32.0), @@ -249,7 +250,7 @@ def MP_20Pos_landscape(name: str) -> TecanPlateCarrier: size_z=32.0, off_x=11.5, off_y=35.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(557.4, 16.8, 32.0), Coordinate(557.4, 113.7, 32.0), Coordinate(557.4, 209.9, 32.0), @@ -286,7 +287,7 @@ def MP_16Pos_landscape(name: str) -> TecanPlateCarrier: size_z=32.0, off_x=11.5, off_y=35.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(419.0, 16.8, 32.0), Coordinate(419.0, 113.7, 32.0), Coordinate(419.0, 209.9, 32.0), @@ -325,7 +326,7 @@ def MP_3Pos(name: str) -> TecanPlateCarrier: roma_z_safe=946, roma_z_travel=1938, roma_z_end=2537, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(5.5, 13.5, 62.5), Coordinate(5.5, 109.5, 62.5), Coordinate(5.5, 205.5, 62.5), @@ -351,7 +352,7 @@ def MP_3Pos_Cooled(name: str) -> TecanPlateCarrier: roma_z_safe=946, roma_z_travel=1853, roma_z_end=2534, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(17.0, 27.5, 54.0), Coordinate(17.0, 123.5, 54.0), Coordinate(17.0, 219.5, 54.0), @@ -377,7 +378,7 @@ def MP_3Pos_Fixed(name: str) -> TecanPlateCarrier: roma_z_safe=946, roma_z_travel=1938, roma_z_end=2537, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(10.5, 47.6, 62.5), Coordinate(10.5, 143.6, 62.5), Coordinate(10.5, 239.6, 62.5), @@ -403,7 +404,7 @@ def MP_3Pos_Flat(name: str) -> TecanPlateCarrier: size_z=6.0, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(11.7, 10.5, 6.0), Coordinate(11.0, 106.4, 6.0), Coordinate(11.0, 202.8, 6.0), @@ -427,7 +428,7 @@ def MP_3Pos_Flat(name: str) -> TecanPlateCarrier: roma_z_safe=610, roma_z_travel=2418, roma_z_end=2503, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(10.4, 11.5, 6.0), Coordinate(10.4, 107.5, 6.0), Coordinate(10.4, 203.5, 6.0), @@ -448,7 +449,7 @@ def MP_3Pos_No_Robot_Access(name: str) -> TecanPlateCarrier: size_z=62.5, off_x=12.0, off_y=24.7, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(5.5, 13.5, 62.5), Coordinate(5.5, 113.5, 62.5), Coordinate(5.5, 213.5, 62.5), @@ -474,7 +475,7 @@ def MP_4Pos(name: str) -> TecanPlateCarrier: roma_z_safe=946, roma_z_travel=1938, roma_z_end=2476, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(10.0, 3.5, 62.7), Coordinate(10.0, 99.5, 62.7), Coordinate(10.0, 195.5, 62.7), @@ -501,7 +502,7 @@ def MP_4Pos_flat(name: str) -> TecanPlateCarrier: roma_z_safe=946, roma_z_travel=1938, roma_z_end=2475, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=PlateCarrierSite, locations=[ Coordinate(10.0, 3.5, 6.9), Coordinate(10.0, 99.5, 6.9), Coordinate(10.0, 195.5, 6.9), diff --git a/pylabrobot/resources/tecan/plates.py b/pylabrobot/resources/tecan/plates.py index c21ebdd23e..66fd86ff7c 100644 --- a/pylabrobot/resources/tecan/plates.py +++ b/pylabrobot/resources/tecan/plates.py @@ -8,9 +8,9 @@ # pylint: disable=invalid-name # pylint: disable=line-too-long -from typing import List, Optional -from pylabrobot.resources.plate import Plate, Well -from pylabrobot.resources.itemized_resource import create_equally_spaced +from typing import Dict, Optional +from pylabrobot.resources.plate import Lid, Plate, Well +from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.tecan.tecan_resource import TecanResource @@ -28,14 +28,13 @@ def __init__( z_dispense: float, z_max: float, area: float, - items: Optional[List[List[Well]]] = None, + ordered_items: Optional[Dict[str, Well]] = None, category: str = "tecan_plate", - lid_height: float = 0, - with_lid: bool = False, + lid: Optional[Lid] = None, model: Optional[str] = None ): - super().__init__(name, size_x, size_y, size_z, items, - category=category, lid_height=lid_height, with_lid=with_lid, model=model) + super().__init__(name, size_x, size_y, size_z, ordered_items=ordered_items, + category=category, lid=lid, model=model) self.z_travel = z_travel self.z_start = z_start @@ -44,6 +43,20 @@ def __init__( self.area = area + +def Microplate_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=129.9, + # size_y=83.9, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Microplate_96_Well_Lid", + # ) + + def Microplate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: """ white: pn 30122300, black: pn 30122298, cell culture/clear: pn 30122304, cell culture/black with clear bottom: pn 30122306 @@ -64,7 +77,7 @@ def Microplate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: z_dispense=1970.0, z_max=2026.0, area=33.2, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.8, @@ -74,7 +87,7 @@ def Microplate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=9.0, size_x=9.0, size_y=9.0, - size_z=5.6, + size_z=22.6, ), ) """ @@ -84,15 +97,14 @@ def Microplate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: size_x=127.8, size_y=85.4, size_z=7.6, - with_lid=with_lid, - lid_height=8, + lid=Microplate_96_Well_Lid(name=name + "_lid") if with_lid else None, model="Microplate_96_Well", z_travel=1900.0, z_start=1957.0, z_dispense=1975.0, z_max=2005.0, area=33.2, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=12.5, @@ -102,26 +114,38 @@ def Microplate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=9.0, size_x=9.0, size_y=9.0, - size_z=5.6, + size_z=22.6, ), ) +def Microplate_portrait_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=85.4, + # size_y=127.8, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Microplate_portrait_96_Well_Lid", + # ) + + def Microplate_portrait_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=85.4, size_y=127.8, - size_z=9.0, - with_lid=with_lid, - lid_height=8, + size_z=11.0, + lid=Microplate_portrait_96_Well_Lid(name=name + "_lid") if with_lid else None, model="Microplate_portrait_96_Well", z_travel=1900.0, z_start=1940.0, z_dispense=1960.0, z_max=2050.0, area=33.2, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=8, num_items_y=12, dx=6.7, @@ -131,26 +155,38 @@ def Microplate_portrait_96_Well(name: str, with_lid: bool = False) -> TecanPlate item_dy=9.0, size_x=9.0, size_y=9.0, - size_z=9.0, + size_z=11.0, ), ) +def DeepWell_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.8, + # size_y=85.4, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="DeepWell_96_Well_Lid", + # ) + + def DeepWell_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=127.8, size_y=85.4, - size_z=37.0, - with_lid=with_lid, - lid_height=8, + size_z=39.0, + lid=DeepWell_96_Well_Lid(name=name + "_lid") if with_lid else None, model="DeepWell_96_Well", z_travel=1590.0, z_start=1670.0, z_dispense=1690.0, z_max=2060.0, area=33.2, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=9.9, @@ -160,26 +196,38 @@ def DeepWell_96_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=9.0, size_x=9.0, size_y=9.0, - size_z=37.0, + size_z=39.0, ), ) +def HalfDeepWell_384_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.7, + # size_y=85.5, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="HalfDeepWell_384_Well_Lid", + # ) + + def HalfDeepWell_384_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=127.7, size_y=85.5, - size_z=16.8, - with_lid=with_lid, - lid_height=8, + size_z=18.8, + lid=HalfDeepWell_384_Well_Lid(name=name + "_lid") if with_lid else None, model="HalfDeepWell_384_Well", z_travel=1789.0, z_start=1869.0, z_dispense=1889.0, z_max=2057.0, area=33.2, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=16, dx=9.85, @@ -189,26 +237,38 @@ def HalfDeepWell_384_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=4.5, size_x=4.5, size_y=4.5, - size_z=16.8, + size_z=18.8, ), ) +def DeepWell_portait_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=85.4, + # size_y=127.8, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="DeepWell_portait_96_Well_Lid", + # ) + + def DeepWell_portait_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=85.4, size_y=127.8, - size_z=36.0, - with_lid=with_lid, - lid_height=8, + size_z=38.0, + lid=DeepWell_portait_96_Well_Lid(name=name + "_lid") if with_lid else None, model="DeepWell_portait_96_Well", z_travel=1625.0, z_start=1670.0, z_dispense=1690.0, z_max=2050.0, area=33.2, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=8, num_items_y=12, dx=6.7, @@ -218,26 +278,38 @@ def DeepWell_portait_96_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=9.0, size_x=9.0, size_y=9.0, - size_z=36.0, + size_z=38.0, ), ) +def Plate_portrait_384_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=85.5, + # size_y=127.7, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Plate_portrait_384_Well_Lid", + # ) + + def Plate_portrait_384_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=85.5, size_y=127.7, - size_z=9.0, - with_lid=with_lid, - lid_height=8, + size_z=11.0, + lid=Plate_portrait_384_Well_Lid(name=name + "_lid") if with_lid else None, model="Plate_portrait_384_Well", z_travel=1900.0, z_start=1940.0, z_dispense=1960.0, z_max=2050.0, area=9.0, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=16, num_items_y=24, dx=6.75, @@ -247,26 +319,38 @@ def Plate_portrait_384_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=4.5, size_x=4.5, size_y=4.5, - size_z=9.0, + size_z=11.0, ), ) +def Macherey_Nagel_Plate_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=151.6, + # size_y=131.1, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Macherey_Nagel_Plate_96_Well_Lid", + # ) + + def Macherey_Nagel_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=151.6, size_y=131.1, - size_z=25.3, - with_lid=with_lid, - lid_height=8, + size_z=29.9, + lid=Macherey_Nagel_Plate_96_Well_Lid(name=name + "_lid") if with_lid else None, model="Macherey_Nagel_Plate_96_Well", z_travel=1514.0, z_start=1532.0, z_dispense=1578.0, z_max=1831.0, area=65.0, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=22.25, @@ -276,26 +360,38 @@ def Macherey_Nagel_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlat item_dy=8.9, size_x=8.9, size_y=8.9, - size_z=25.3, + size_z=29.9, ), ) +def Qiagen_Plate_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=151.7, + # size_y=132.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Qiagen_Plate_96_Well_Lid", + # ) + + def Qiagen_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=151.7, size_y=132.0, - size_z=25.8, - with_lid=with_lid, - lid_height=8, + size_z=26.6, + lid=Qiagen_Plate_96_Well_Lid(name=name + "_lid") if with_lid else None, model="Qiagen_Plate_96_Well", z_travel=1493.0, z_start=1541.0, z_dispense=1549.0, z_max=1807.0, area=60.8, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=22.25, @@ -305,26 +401,38 @@ def Qiagen_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=8.9, size_x=8.9, size_y=8.9, - size_z=25.8, + size_z=26.6, ), ) +def AB_Plate_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=130.9, + # size_y=128.8, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="AB_Plate_96_Well_Lid", + # ) + + def AB_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=130.9, size_y=128.8, - size_z=18.0, - with_lid=with_lid, - lid_height=8, + size_z=19.5, + lid=AB_Plate_96_Well_Lid(name=name + "_lid") if with_lid else None, model="AB_Plate_96_Well", z_travel=1772.0, z_start=1822.0, z_dispense=1837.0, z_max=2017.0, area=26.4, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=11.4, @@ -334,26 +442,38 @@ def AB_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=9.0, size_x=9.0, size_y=9.0, - size_z=18.0, + size_z=19.5, ), ) +def PCR_Plate_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=128.0, + # size_y=83.2, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="PCR_Plate_96_Well_Lid", + # ) + + def PCR_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=128.0, size_y=83.2, - size_z=18.0, - with_lid=with_lid, - lid_height=8, + size_z=19.5, + lid=PCR_Plate_96_Well_Lid(name=name + "_lid") if with_lid else None, model="PCR_Plate_96_Well", z_travel=1857.0, z_start=1900.0, z_dispense=1915.0, z_max=2095.0, area=28.3, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.1, @@ -363,26 +483,38 @@ def PCR_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=9.0, size_x=9.0, size_y=9.0, - size_z=18.0, + size_z=19.5, ), ) +def DeepWell_Greiner_1536_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.8, + # size_y=85.5, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="DeepWell_Greiner_1536_Well_Lid", + # ) + + def DeepWell_Greiner_1536_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=127.8, size_y=85.5, - size_z=6.6, - with_lid=with_lid, - lid_height=8, + size_z=8.6, + lid=DeepWell_Greiner_1536_Well_Lid(name=name + "_lid") if with_lid else None, model="DeepWell_Greiner_1536_Well", z_travel=1946.0, z_start=1984.0, z_dispense=2004.0, z_max=2070.0, area=2.7, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=48, num_items_y=32, dx=9.85, @@ -392,26 +524,38 @@ def DeepWell_Greiner_1536_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=2.3, size_x=2.3, size_y=2.3, - size_z=6.6, + size_z=8.6, ), ) +def Hibase_Greiner_1536_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.8, + # size_y=85.5, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Hibase_Greiner_1536_Well_Lid", + # ) + + def Hibase_Greiner_1536_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=127.8, size_y=85.5, - size_z=3.4, - with_lid=with_lid, - lid_height=8, + size_z=5.4, + lid=Hibase_Greiner_1536_Well_Lid(name=name + "_lid") if with_lid else None, model="Hibase_Greiner_1536_Well", z_travel=1946.0, z_start=1984.0, z_dispense=2004.0, z_max=2038.0, area=2.5, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=48, num_items_y=32, dx=9.85, @@ -421,26 +565,38 @@ def Hibase_Greiner_1536_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=2.3, size_x=2.3, size_y=2.3, - size_z=3.4, + size_z=5.4, ), ) +def Lowbase_Greiner_1536_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.8, + # size_y=85.5, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Lowbase_Greiner_1536_Well_Lid", + # ) + + def Lowbase_Greiner_1536_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=127.8, size_y=85.5, - size_z=5.2, - with_lid=with_lid, - lid_height=8, + size_z=6.2, + lid=Lowbase_Greiner_1536_Well_Lid(name=name + "_lid") if with_lid else None, model="Lowbase_Greiner_1536_Well", z_travel=1946.0, z_start=2024.0, z_dispense=2034.0, z_max=2086.0, area=2.7, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=48, num_items_y=32, dx=9.85, @@ -450,26 +606,38 @@ def Lowbase_Greiner_1536_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=2.3, size_x=2.3, size_y=2.3, - size_z=5.2, + size_z=6.2, ), ) +def Separation_Plate_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=151.7, + # size_y=132.0, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Separation_Plate_96_Well_Lid", + # ) + + def Separation_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=151.7, size_y=132.0, - size_z=25.8, - with_lid=with_lid, - lid_height=8, + size_z=26.6, + lid=Separation_Plate_96_Well_Lid(name=name + "_lid") if with_lid else None, model="Separation_Plate_96_Well", z_travel=1493.0, z_start=1541.0, z_dispense=1549.0, z_max=1807.0, area=60.8, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=22.25, @@ -479,26 +647,38 @@ def Separation_Plate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=8.9, size_x=8.9, size_y=8.9, - size_z=25.8, + size_z=26.6, ), ) +def DeepWell_square_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.8, + # size_y=85.4, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="DeepWell_square_96_Well_Lid", + # ) + + def DeepWell_square_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=127.8, size_y=85.4, - size_z=37.0, - with_lid=with_lid, - lid_height=8, + size_z=39.0, + lid=DeepWell_square_96_Well_Lid(name=name + "_lid") if with_lid else None, model="DeepWell_square_96_Well", z_travel=1590.0, z_start=1670.0, z_dispense=1690.0, z_max=2060.0, area=64.0, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=9.9, @@ -508,26 +688,38 @@ def DeepWell_square_96_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=9.0, size_x=9.0, size_y=9.0, - size_z=37.0, + size_z=39.0, ), ) +def CaCo2_Plate_24_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=125.2, + # size_y=89.2, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="CaCo2_Plate_24_Well_Lid", + # ) + + def CaCo2_Plate_24_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=125.2, size_y=89.2, - size_z=1.8, - with_lid=with_lid, - lid_height=8, + size_z=6.5, + lid=CaCo2_Plate_24_Well_Lid(name=name + "_lid") if with_lid else None, model="CaCo2_Plate_24_Well", z_travel=1774.0, z_start=1960.0, z_dispense=2007.0, z_max=2025.0, area=50.3, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=6, num_items_y=4, dx=4.75, @@ -537,27 +729,39 @@ def CaCo2_Plate_24_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=19.3, size_x=19.3, size_y=19.3, - size_z=1.8, + size_z=6.5, ), ) +def Plate_384_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=127.7, + # size_y=85.5, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Plate_384_Well_Lid", + # ) + + def Plate_384_Well(name: str, with_lid: bool = False) -> TecanPlate: """ white: pn 30122301, black: pn 30122299, cell culture/clear: pn 30122305, cell culture/black with clear bottom: pn 30122307 """ return TecanPlate( name=name, size_x=127.7, size_y=85.5, - size_z=10.1, - with_lid=with_lid, - lid_height=8, + size_z=12.1, + lid=Plate_384_Well_Lid(name=name + "_lid") if with_lid else None, model="Plate_384_Well", z_travel=1900.0, z_start=1940.0, z_dispense=1960.0, z_max=2061.0, area=13.7, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=24, num_items_y=16, dx=9.85, @@ -567,27 +771,39 @@ def Plate_384_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=4.5, size_x=4.5, size_y=4.5, - size_z=10.1, + size_z=12.1, ), ) +def Microplate_24_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=130.9, + # size_y=85.5, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Microplate_24_Well_Lid", + # ) + + def Microplate_24_Well(name: str, with_lid: bool = False) -> TecanPlate: """ cell culture/clear: pn 30122302 """ return TecanPlate( name=name, size_x=130.9, size_y=85.5, - size_z=14.4, - with_lid=with_lid, - lid_height=8, + size_z=17.6, + lid=Microplate_24_Well_Lid(name=name + "_lid") if with_lid else None, model="Microplate_24_Well", z_travel=1841.0, z_start=1885.0, z_dispense=1917.0, z_max=2061.0, area=193.6, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=6, num_items_y=4, dx=6.6, @@ -597,26 +813,38 @@ def Microplate_24_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=19.6, size_x=19.6, size_y=19.6, - size_z=14.4, + size_z=17.6, ), ) +def TecanExtractionPlate_96_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=129.8, + # size_y=91.7, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="TecanExtractionPlate_96_Well_Lid", + # ) + + def TecanExtractionPlate_96_Well(name: str, with_lid: bool = False) -> TecanPlate: return TecanPlate( name=name, size_x=129.8, size_y=91.7, - size_z=15.1, - with_lid=with_lid, - lid_height=8, + size_z=23.0, + lid=TecanExtractionPlate_96_Well_Lid(name=name + "_lid") if with_lid else None, model="TecanExtractionPlate_96_Well", z_travel=1793.0, z_start=1831.0, z_dispense=1910.0, z_max=2061.0, area=33.2, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=12, num_items_y=8, dx=10.9, @@ -626,27 +854,39 @@ def TecanExtractionPlate_96_Well(name: str, with_lid: bool = False) -> TecanPlat item_dy=9.0, size_x=9.0, size_y=9.0, - size_z=15.1, + size_z=23.0, ), ) +def Microplate_48_Well_Lid(name: str) -> Lid: + raise NotImplementedError("This lid is not currently defined.") + # See https://github.com/PyLabRobot/pylabrobot/pull/161. + # return Lid( + # name=name, + # size_x=131.1, + # size_y=85.3, + # size_z=None, # measure the total z height + # nesting_z_height=None, # measure overlap between lid and plate + # model="Microplate_48_Well_Lid", + # ) + + def Microplate_48_Well(name: str, with_lid: bool = False) -> TecanPlate: """ cell culture/clear: pn 30122303 """ return TecanPlate( name=name, size_x=131.1, size_y=85.3, - size_z=13.9, - with_lid=with_lid, - lid_height=8, + size_z=17.3, + lid=Microplate_48_Well_Lid(name=name + "_lid") if with_lid else None, model="Microplate_48_Well", z_travel=1839.0, z_start=1887.0, z_dispense=1921.0, z_max=2060.0, area=102.1, - items=create_equally_spaced(Well, + ordered_items=create_ordered_items_2d(Well, num_items_x=8, num_items_y=6, dx=13.5, @@ -656,243 +896,6 @@ def Microplate_48_Well(name: str, with_lid: bool = False) -> TecanPlate: item_dy=13.0, size_x=13.0, size_y=13.0, - size_z=13.9, - ), - ) - - -# ----- Resources below this line are probably outdated ----- - - -def Microplate_Nuncflat_96_Well(name: str, with_lid: bool = False) -> TecanPlate: - """ white: pn 30122300, black: pn 30122298, cell culture/clear: pn 30122304, cell culture/black with clear bottom: pn 30122306 """ - return TecanPlate( - name=name, - size_x=133.1, - size_y=88.3, - size_z=4.7, - with_lid=with_lid, - lid_height=8, - model="Microplate_Nuncflat_96_Well", - z_travel=1874.0, - z_start=1939.0, - z_dispense=1973.0, - z_max=2020.0, - area=33.2, - items=create_equally_spaced(Well, - num_items_x=12, - num_items_y=8, - dx=13.15, - dy=8.45, - dz=0.0, - item_dx=8.9, - item_dy=8.9, - size_x=8.9, - size_y=8.9 - ), - ) - - -def Plate_ARTEL_384_Well(name: str, with_lid: bool = False) -> TecanPlate: - """ white: pn 30122301, black: pn 30122299, cell culture/clear: pn 30122305, cell culture/black with clear bottom: pn 30122307 """ - return TecanPlate( - name=name, - size_x=133.4, - size_y=90.5, - size_z=7.0, - with_lid=with_lid, - lid_height=8, - model="Plate_ARTEL_384_Well", - z_travel=1900.0, - z_start=1940.0, - z_dispense=1960.0, - z_max=2030.0, - area=13.7, - items=create_equally_spaced(Well, - num_items_x=24, - num_items_y=16, - dx=12.45, - dy=9.15, - dz=0.0, - item_dx=4.5, - item_dy=4.5, - size_x=4.5, - size_y=4.5 - ), - ) - - -def Plate_greiner_384_Well(name: str, with_lid: bool = False) -> TecanPlate: - """ white: pn 30122301, black: pn 30122299, cell culture/clear: pn 30122305, cell culture/black with clear bottom: pn 30122307 """ - return TecanPlate( - name=name, - size_x=132.1, - size_y=89.8, - size_z=7.0, - with_lid=with_lid, - lid_height=8, - model="Plate_greiner_384_Well", - z_travel=1900.0, - z_start=1940.0, - z_dispense=1960.0, - z_max=2030.0, - area=14.4, - items=create_equally_spaced(Well, - num_items_x=24, - num_items_y=16, - dx=11.95, - dy=9.15, - dz=0.0, - item_dx=4.5, - item_dy=4.5, - size_dx=4.5, - size_dy=4.5 - ), - ) - - -def greiner_no_change_384_Well(name: str, with_lid: bool = False) -> TecanPlate: - """ white: pn 30122301, black: pn 30122299, cell culture/clear: pn 30122305, cell culture/black with clear bottom: pn 30122307 """ - return TecanPlate( - name=name, - size_x=132.1, - size_y=89.8, - size_z=7.0, - with_lid=with_lid, - lid_height=8, - model="greiner_no_change_384_Well", - z_travel=1900.0, - z_start=1940.0, - z_dispense=1960.0, - z_max=2030.0, - area=14.4, - items=create_equally_spaced(Well, - num_items_x=24, - num_items_y=16, - dx=11.95, - dy=9.15, - dz=0.0, - item_dx=4.5, - item_dy=4.5, - size_dx=4.5, - size_dy=4.5 - ), - ) - - -def Microplate_Nunc_v_96_Well(name: str, with_lid: bool = False) -> TecanPlate: - """ white: pn 30122300, black: pn 30122298, cell culture/clear: pn 30122304, cell culture/black with clear bottom: pn 30122306 """ - return TecanPlate( - name=name, - size_x=131.8, - size_y=88.4, - size_z=3.8, - with_lid=with_lid, - lid_height=8, - model="Microplate_Nunc_v_96_Well", - z_travel=1874.0, - z_start=1939.0, - z_dispense=1973.0, - z_max=2011.0, - area=33.2, - items=create_equally_spaced(Well, - num_items_x=12, - num_items_y=8, - dx=11.9, - dy=8.5, - dz=0.0, - item_dx=4.5, - item_dy=4.5, - size_dx=4.5, - size_dy=4.5 - ), - ) - - -def Microplate_Nunc_96_Well(name: str, with_lid: bool = False) -> TecanPlate: - """ white: pn 30122300, black: pn 30122298, cell culture/clear: pn 30122304, cell culture/black with clear bottom: pn 30122306 """ - return TecanPlate( - name=name, - size_x=133.1, - size_y=88.3, - size_z=4.7, - with_lid=with_lid, - lid_height=8, - model="Microplate_Nunc_96_Well", - z_travel=1874.0, - z_start=1939.0, - z_dispense=1973.0, - z_max=2020.0, - area=33.2, - items=create_equally_spaced(Well, - num_items_x=12, - num_items_y=8, - dx=13.15, - dy=8.45, - dz=0.0, - item_dx=4.5, - item_dy=4.5, - size_dx=4.5, - size_dy=4.5 - ), - ) - - -def Plate_Corning_384_Well(name: str, with_lid: bool = False) -> TecanPlate: - """ white: pn 30122301, black: pn 30122299, cell culture/clear: pn 30122305, cell culture/black with clear bottom: pn 30122307 """ - return TecanPlate( - name=name, - size_x=132.2, - size_y=87.7, - size_z=7.0, - with_lid=with_lid, - lid_height=8, - model="Plate_Corning_384_Well", - z_travel=1900.0, - z_start=1940.0, - z_dispense=1960.0, - z_max=2030.0, - area=13.7, - items=create_equally_spaced(Well, - num_items_x=24, - num_items_y=16, - dx=12.35, - dy=7.95, - dz=0.0, - item_dx=4.5, - item_dy=4.5, - size_dx=4.5, - size_dy=4.5 - ), - ) - - -def Plate_Corning_No_384_Well(name: str, with_lid: bool = False) -> TecanPlate: - """ white: pn 30122301, black: pn 30122299, cell culture/clear: pn 30122305, cell culture/black with clear bottom: pn 30122307 """ - return TecanPlate( - name=name, - size_x=132.6, - size_y=88.2, - size_z=7.0, - with_lid=with_lid, - lid_height=8, - model="Plate_Corning_No_384_Well", - z_travel=1900.0, - z_start=1940.0, - z_dispense=1960.0, - z_max=2030.0, - area=13.7, - items=create_equally_spaced(Well, - num_items_x=24, - num_items_y=16, - dx=12.45, - dy=7.95, - dz=0.0, - item_dx=4.5, - item_dy=4.5, - size_dx=4.5, - size_dy=4.5, - size_y=13.0, - size_z=13.9, + size_z=17.3, ), ) diff --git a/pylabrobot/resources/tecan/tecan_decks.py b/pylabrobot/resources/tecan/tecan_decks.py index ce8414fe71..29ec1d0a6e 100644 --- a/pylabrobot/resources/tecan/tecan_decks.py +++ b/pylabrobot/resources/tecan/tecan_decks.py @@ -93,9 +93,9 @@ def assign_child_resource( resource_location = None # unknown resource location if resource_location is not None: - if resource_location.x + resource.get_size_x() > self.get_size_x() and \ + if resource_location.x + resource.get_absolute_size_x() > self.get_absolute_size_x() and \ rails is not None: - raise ValueError(f"Resource with width {resource.get_size_x()} does not " + raise ValueError(f"Resource with width {resource.get_absolute_size_x()} does not " f"fit at rails {rails}.") # Check if there is space for this new resource. @@ -105,12 +105,12 @@ def assign_child_resource( # A resource is not allowed to overlap with another resource. Resources overlap when a # corner of one resource is inside the boundaries other resource. - if (og_x <= resource_location.x < og_x + og_resource.get_size_x() or \ - og_x <= resource_location.x + resource.get_size_x() < - og_x + og_resource.get_size_x()) and \ - (og_y <= resource_location.y < og_y + og_resource.get_size_y() or \ - og_y <= resource_location.y + resource.get_size_y() < - og_y + og_resource.get_size_y()): + if (og_x <= resource_location.x < og_x + og_resource.get_absolute_size_x() or \ + og_x <= resource_location.x + resource.get_absolute_size_x() < + og_x + og_resource.get_absolute_size_x()) and \ + (og_y <= resource_location.y < og_y + og_resource.get_absolute_size_y() or \ + og_y <= resource_location.y + resource.get_absolute_size_y() < + og_y + og_resource.get_absolute_size_y()): raise ValueError(f"Location {resource_location} is already occupied by resource " f"'{og_resource.name}'.") @@ -124,7 +124,7 @@ def _coordinate_for_rails(self, rails: int, resource: Resource): return Coordinate( (rails - 1) * _RAILS_WIDTH - resource.off_x + 100, - resource.off_y + 345 - resource.get_size_y(), 0) # TODO: verify + resource.off_y + 345 - resource.get_absolute_size_y(), 0) # TODO: verify def _rails_for_x_coordinate(self, x: float): """ Convert an x coordinate to a rail identifier. """ diff --git a/pylabrobot/resources/tecan/tip_carriers.py b/pylabrobot/resources/tecan/tip_carriers.py index 106c43cff0..e98f4eca53 100644 --- a/pylabrobot/resources/tecan/tip_carriers.py +++ b/pylabrobot/resources/tecan/tip_carriers.py @@ -45,7 +45,7 @@ def Washstation_2Grid_Trough_DiTi(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.5, off_y=14.5, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(0.0, 352.0, 6.0), Coordinate(0.0, -15.0, 6.0), Coordinate(25.5, 94.6, 16.0), @@ -85,7 +85,7 @@ def MCA384_DiTi_Carrier(name: str) -> TecanTipCarrier: size_z=154.0, off_x=13.3, off_y=22.2, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(11.7, 46.7, 31.0), Coordinate(11.7, 186.8, 31.0), ], @@ -105,7 +105,7 @@ def DiTi_2Pos___Waste(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=24.7, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(3.0, 0.0, 4.5), Coordinate(13.3, 170.0, 4.5), Coordinate(13.3, 270.0, 4.5), @@ -131,7 +131,7 @@ def DiTi_3Pos(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=24.7, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(13.3, 70.0, 4.5), Coordinate(13.3, 170.0, 4.5), Coordinate(13.3, 270.0, 4.5), @@ -152,7 +152,7 @@ def DiTi_SBS_3_Pos_MCA96(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 12.0, 6.0), Coordinate(10.5, 108.0, 6.0), Coordinate(10.5, 204.0, 6.0), @@ -173,7 +173,7 @@ def LI___DiTi_3Pos(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=24.7, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(13.4, 42.5, -2.5), Coordinate(13.3, 138.7, -2.5), Coordinate(13.3, 234.1, -2.5), @@ -194,7 +194,7 @@ def DiTi_SBS_2P_Waste_MCA96(name: str) -> TecanTipCarrier: size_z=6.0, off_x=25.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(20.0, -41.0, 125.0), Coordinate(23.5, 184.0, 6.0), Coordinate(23.5, 280.0, 6.0), @@ -220,7 +220,7 @@ def DiTi_SBS_4_Pos_MCA96(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.0, 3.0, 6.0), Coordinate(10.0, 99.0, 6.0), Coordinate(10.0, 195.0, 6.0), @@ -241,7 +241,7 @@ def DiTi_Nest_2P_W_MCA384(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(-7.0, -97.0, 4.0), Coordinate(10.5, 105.0, 4.0), Coordinate(10.5, 201.0, 4.0), @@ -339,7 +339,7 @@ def DiTi_Nest_2P_W_MCA384_Indiv(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.5, off_y=51.5, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(-7.0, -52.0, 4.0), Coordinate(10.5, 144.0, 4.0), Coordinate(10.5, 288.0, 4.0), @@ -437,7 +437,7 @@ def DiTi_Nest_3_Pos_MCA384_Indiv(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 3.0, 4.0), Coordinate(10.5, 147.0, 4.0), Coordinate(10.5, 291.0, 4.0), @@ -482,7 +482,7 @@ def DiTi_Nested_3_Pos_MCA384(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 12.0, 4.0), Coordinate(10.5, 108.0, 4.0), Coordinate(10.5, 204.0, 4.0), @@ -527,7 +527,7 @@ def DiTi_Nested_4_Pos_MCA384(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 3.0, 4.0), Coordinate(10.5, 99.0, 4.0), Coordinate(10.5, 195.0, 4.0), @@ -581,7 +581,7 @@ def DiTi_SBS_2P_W_MCA38_Indiv(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.0, off_y=51.5, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(-7.0, -53.5, 164.0), Coordinate(10.5, 144.0, 4.0), Coordinate(10.5, 288.0, 4.0), @@ -607,7 +607,7 @@ def DiTi_SBS_2P_W_MCA384(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(-7.0, -97.0, 164.0), Coordinate(10.5, 105.0, 4.0), Coordinate(10.5, 201.0, 4.0), @@ -633,7 +633,7 @@ def DiTi_SBS_3_Pos_MCA384(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 12.0, 4.0), Coordinate(10.5, 108.0, 4.0), Coordinate(10.5, 204.0, 4.0), @@ -654,7 +654,7 @@ def DiTi_SBS_3_Pos_MCA384_Indiv(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 3.0, 4.0), Coordinate(10.5, 147.0, 4.0), Coordinate(10.5, 291.0, 4.0), @@ -675,7 +675,7 @@ def DiTi_SBS_4_Pos_MCA384(name: str) -> TecanTipCarrier: size_z=4.0, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 3.0, 4.0), Coordinate(10.5, 99.0, 4.0), Coordinate(10.5, 195.0, 4.0), @@ -697,7 +697,7 @@ def DiTi_Nest_2_W_MCA96_100(name: str) -> TecanTipCarrier: size_z=6.0, off_x=25.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(20.0, -41.0, 6.0), Coordinate(23.5, 184.0, 6.0), Coordinate(23.5, 280.0, 6.0), @@ -795,7 +795,7 @@ def DiTi_Nest_2_W_MCA96_200(name: str) -> TecanTipCarrier: size_z=6.0, off_x=25.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(20.0, -41.0, 6.0), Coordinate(23.5, 184.0, 6.0), Coordinate(23.5, 280.0, 6.0), @@ -893,7 +893,7 @@ def DiTi_Nest_2_W_MCA96_50(name: str) -> TecanTipCarrier: size_z=6.0, off_x=25.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(20.0, -41.0, 6.0), Coordinate(23.5, 184.0, 6.0), Coordinate(23.5, 280.0, 6.0), @@ -991,7 +991,7 @@ def DiTi_Nest_3_Pos_MCA96_100(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 12.0, 6.0), Coordinate(10.5, 108.0, 6.0), Coordinate(10.5, 204.0, 6.0), @@ -1036,7 +1036,7 @@ def DiTi_Nest_3_Pos_MCA96_200(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 12.0, 6.0), Coordinate(10.5, 108.0, 6.0), Coordinate(10.5, 204.0, 6.0), @@ -1081,7 +1081,7 @@ def DiTi_Nest_3_Pos_MCA96_50(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 12.0, 6.0), Coordinate(10.5, 108.0, 6.0), Coordinate(10.5, 204.0, 6.0), @@ -1126,7 +1126,7 @@ def DiTi_Nest_4_Pos_MCA96_100(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.0, 3.0, 6.0), Coordinate(10.0, 99.0, 6.0), Coordinate(10.0, 195.0, 6.0), @@ -1180,7 +1180,7 @@ def DiTi_Nest_4_Pos_MCA96_200(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.0, 3.0, 6.0), Coordinate(10.0, 99.0, 6.0), Coordinate(10.0, 195.0, 6.0), @@ -1234,7 +1234,7 @@ def DiTi_Nest_4_Pos_MCA96_50(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.0, 3.0, 6.0), Coordinate(10.0, 99.0, 6.0), Coordinate(10.0, 195.0, 6.0), @@ -1288,7 +1288,7 @@ def DiTi_3Pos___Waste(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=24.7, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(6.0, 20.0, 4.5), Coordinate(13.3, 167.0, 4.5), Coordinate(13.3, 256.5, 4.5), @@ -1317,7 +1317,7 @@ def DiTi_Nest_2_W_MCA384_100(name: str) -> TecanTipCarrier: size_z=6.0, off_x=25.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(20.0, -41.0, 6.0), Coordinate(23.5, 184.0, 6.0), Coordinate(23.5, 280.0, 6.0), @@ -1415,7 +1415,7 @@ def DiTi_Nest_2_W_MCA384_200(name: str) -> TecanTipCarrier: size_z=6.0, off_x=25.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(20.0, -41.0, 6.0), Coordinate(23.5, 184.0, 6.0), Coordinate(23.5, 280.0, 6.0), @@ -1513,7 +1513,7 @@ def DiTi_Nest_2_W_MCA384_50(name: str) -> TecanTipCarrier: size_z=6.0, off_x=25.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(20.0, -41.0, 6.0), Coordinate(23.5, 184.0, 6.0), Coordinate(23.5, 280.0, 6.0), @@ -1611,7 +1611,7 @@ def DiTi_Waste_station_6_Trough(name: str) -> TecanTipCarrier: size_z=6.0, off_x=12.5, off_y=14.5, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(0.0, 352.0, 6.0), Coordinate(0.0, -15.0, 6.0), Coordinate(25.5, 94.6, 16.0), @@ -1652,7 +1652,7 @@ def DiTi_Nest_2_W_LiHa_350(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, -137.0, 4.5), Coordinate(10.5, -137.0, 4.5), Coordinate(10.5, -137.0, 4.5), @@ -1714,7 +1714,7 @@ def DiTi_Nest_3_Pos_LiHa_350(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 12.0, 57.5), Coordinate(10.5, 12.0, 78.5), Coordinate(10.5, 12.0, 99.5), @@ -1747,7 +1747,7 @@ def DiTi_Nest_3_W_LiHa_350(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.5, off_y=51.5, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, -92.0, 4.5), Coordinate(10.5, -92.0, 4.5), Coordinate(10.5, -92.0, 4.5), @@ -1824,7 +1824,7 @@ def DiTi_Nest_4_Pos_LiHa_350(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 3.0, 57.5), Coordinate(10.5, 3.0, 78.5), Coordinate(10.5, 3.0, 99.5), @@ -1862,7 +1862,7 @@ def DiTi_Nest_2_W_LiHa_10(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, -137.0, 4.5), Coordinate(10.5, -137.0, 4.5), Coordinate(10.5, -137.0, 4.5), @@ -1924,7 +1924,7 @@ def DiTi_Nest_2_W_LiHa_10_F(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=11.0, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, -137.0, 4.5), Coordinate(10.5, -137.0, 4.5), Coordinate(10.5, -137.0, 4.5), @@ -1986,7 +1986,7 @@ def DiTi_Nest_3_Pos_LiHa_10(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 12.0, 57.5), Coordinate(10.5, 12.0, 78.5), Coordinate(10.5, 12.0, 99.5), @@ -2019,7 +2019,7 @@ def DiTi_Nest_3_Pos_LiHa_10_F(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.0, off_y=11.0, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 12.0, 57.5), Coordinate(10.5, 12.0, 78.5), Coordinate(10.5, 12.0, 99.5), @@ -2052,7 +2052,7 @@ def DiTi_Nest_3_W_LiHa_10(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.5, off_y=51.5, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, -92.0, 4.5), Coordinate(10.5, -92.0, 4.5), Coordinate(10.5, -92.0, 4.5), @@ -2129,7 +2129,7 @@ def DiTi_Nest_3_W_LiHa_10_F(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.5, off_y=51.5, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, -92.0, 4.5), Coordinate(10.5, -92.0, 4.5), Coordinate(10.5, -92.0, 4.5), @@ -2206,7 +2206,7 @@ def DiTi_Nest_4_Pos_LiHa_10(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 3.0, 57.5), Coordinate(10.5, 3.0, 78.5), Coordinate(10.5, 3.0, 99.5), @@ -2244,7 +2244,7 @@ def DiTi_Nest_4_Pos_LiHa_10_F(name: str) -> TecanTipCarrier: size_z=4.5, off_x=12.5, off_y=51.5, - sites=create_homogeneous_carrier_sites(locations=[ + sites=create_homogeneous_carrier_sites(klass=CarrierSite, locations=[ Coordinate(10.5, 3.0, 57.5), Coordinate(10.5, 3.0, 78.5), Coordinate(10.5, 3.0, 99.5), diff --git a/pylabrobot/resources/tecan/tip_creators.py b/pylabrobot/resources/tecan/tip_creators.py index 91bcf35530..8a91102495 100644 --- a/pylabrobot/resources/tecan/tip_creators.py +++ b/pylabrobot/resources/tecan/tip_creators.py @@ -304,7 +304,7 @@ def DiTi_500ul_SBS_MCA96_tip() -> TecanTip: def DiTi_Nested_Waste_MCA384_tip() -> TecanTip: """ Tip for DiTi_Nested_Waste_MCA384 """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=0.0, @@ -326,7 +326,7 @@ def DiTi_1000ul_SBS_LiHa_tip() -> TecanTip: def DiTi_200ul_SBS_LiHa_tip() -> TecanTip: """ Tip for DiTi_200ul_SBS_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.0, @@ -338,7 +338,7 @@ def DiTi_200ul_SBS_LiHa_tip() -> TecanTip: def DiTi_50ul_SBS_LiHa_tip() -> TecanTip: """ Tip for DiTi_50ul_SBS_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.3, @@ -450,7 +450,7 @@ def DiTi_1000ul_LiHa_tip() -> TecanTip: def DiTi_10ul_Filter_LiHa_tip() -> TecanTip: """ Tip for DiTi_10ul_Filter_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-31.3, @@ -462,7 +462,7 @@ def DiTi_10ul_Filter_LiHa_tip() -> TecanTip: def DiTi_10ul_LiHa_tip() -> TecanTip: """ Tip for DiTi_10ul_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-31.3, @@ -474,7 +474,7 @@ def DiTi_10ul_LiHa_tip() -> TecanTip: def DiTi_200ul_Filter_LiHa_tip() -> TecanTip: """ Tip for DiTi_200ul_Filter_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.0, @@ -486,7 +486,7 @@ def DiTi_200ul_Filter_LiHa_tip() -> TecanTip: def DiTi_200ul_LiHa_tip() -> TecanTip: """ Tip for DiTi_200ul_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.0, @@ -498,7 +498,7 @@ def DiTi_200ul_LiHa_tip() -> TecanTip: def DiTi_50ul_Filter_LiHa_tip() -> TecanTip: """ Tip for DiTi_50ul_Filter_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.3, @@ -510,7 +510,7 @@ def DiTi_50ul_Filter_LiHa_tip() -> TecanTip: def DiTi_50ul_LiHa_tip() -> TecanTip: """ Tip for DiTi_50ul_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.3, @@ -522,7 +522,7 @@ def DiTi_50ul_LiHa_tip() -> TecanTip: def DiTi_350ul_Nested_LiHa_tip() -> TecanTip: """ Tip for DiTi_350ul_Nested_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.0, @@ -534,7 +534,7 @@ def DiTi_350ul_Nested_LiHa_tip() -> TecanTip: def DiTi_10ul_Filter_LiHa_L_tip() -> TecanTip: """ Tip for DiTi_10ul_Filter_LiHa_L """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-28.1, @@ -546,7 +546,7 @@ def DiTi_10ul_Filter_LiHa_L_tip() -> TecanTip: def DiTi_10ul_Filter_Nested_LiHa_tip() -> TecanTip: """ Tip for DiTi_10ul_Filter_Nested_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-28.1, @@ -558,7 +558,7 @@ def DiTi_10ul_Filter_Nested_LiHa_tip() -> TecanTip: def DiTi_10ul_LiHa_L_tip() -> TecanTip: """ Tip for DiTi_10ul_LiHa_L """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-28.1, @@ -570,7 +570,7 @@ def DiTi_10ul_LiHa_L_tip() -> TecanTip: def DiTi_10ul_Nested_LiHa_tip() -> TecanTip: """ Tip for DiTi_10ul_Nested_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-28.1, @@ -582,7 +582,7 @@ def DiTi_10ul_Nested_LiHa_tip() -> TecanTip: def DiTi_10ul_SBS_Filter_LiHa_tip() -> TecanTip: """ Tip for DiTi_10ul_SBS_Filter_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-28.1, @@ -594,7 +594,7 @@ def DiTi_10ul_SBS_Filter_LiHa_tip() -> TecanTip: def DiTi_10ul_SBS_LiHa_tip() -> TecanTip: """ Tip for DiTi_10ul_SBS_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-28.1, @@ -636,7 +636,7 @@ def DiTi_1000ul_CL_LiHa_tip() -> TecanTip: def DiTi_200ul_CL_Filter_LiHa_tip() -> TecanTip: """ Tip for DiTi_200ul_CL_Filter_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.0, @@ -648,7 +648,7 @@ def DiTi_200ul_CL_Filter_LiHa_tip() -> TecanTip: def DiTi_200ul_CL_LiHa_tip() -> TecanTip: """ Tip for DiTi_200ul_CL_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.0, @@ -660,7 +660,7 @@ def DiTi_200ul_CL_LiHa_tip() -> TecanTip: def DiTi_50ul_CL_Filter_LiHa_tip() -> TecanTip: """ Tip for DiTi_50ul_CL_Filter_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.3, @@ -672,7 +672,7 @@ def DiTi_50ul_CL_Filter_LiHa_tip() -> TecanTip: def DiTi_50ul_CL_LiHa_tip() -> TecanTip: """ Tip for DiTi_50ul_CL_LiHa """ print("WARNING: total_tip_length <= 0.") - print("Please get in touch at https://forums.pylabrobot.org/c/pylabrobot/23") + print("Please get in touch at https://discuss.pylabrobot.org") return TecanTip( has_filter=False, total_tip_length=-5.3, diff --git a/pylabrobot/resources/tecan/tip_racks.py b/pylabrobot/resources/tecan/tip_racks.py index 71acd2a46d..55740784ef 100644 --- a/pylabrobot/resources/tecan/tip_racks.py +++ b/pylabrobot/resources/tecan/tip_racks.py @@ -4,9 +4,9 @@ # pylint: disable=invalid-name # pylint: disable=line-too-long -from typing import List, Optional +from typing import Dict, Optional from pylabrobot.resources.tip_rack import TipRack, TipSpot -from pylabrobot.resources.itemized_resource import create_equally_spaced +from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.tecan.tecan_resource import TecanResource from .tip_creators import ( DiTi_100ul_Te_MO_tip, @@ -86,11 +86,12 @@ def __init__( z_dispense: float, z_max: float, area: float, - items: Optional[List[List[TipSpot]]] = None, + ordered_items: Optional[Dict[str, TipSpot]] = None, category: str = "tecan_plate", model: Optional[str] = None ): - super().__init__(name, size_x, size_y, size_z, items, category=category, model=model) + super().__init__(name, size_x, size_y, size_z, ordered_items=ordered_items, + category=category, model=model) self.z_travel = z_travel self.z_start = z_start @@ -112,7 +113,7 @@ def DiTi_100ul_Te_MO(name: str) -> TecanTipRack: z_dispense=1280.0, z_max=1430.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.0, @@ -139,7 +140,7 @@ def DiTi_50ul_Te_MO(name: str) -> TecanTipRack: z_dispense=1290.0, z_max=1430.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.0, @@ -166,7 +167,7 @@ def DiTi_200ul_Te_MO(name: str) -> TecanTipRack: z_dispense=1290.0, z_max=1430.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.0, @@ -193,7 +194,7 @@ def DiTi_100ul_Filter_Te_MO(name: str) -> TecanTipRack: z_dispense=1290.0, z_max=1357.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.0, @@ -220,7 +221,7 @@ def DiTi_200ul_Filter_Te_MO(name: str) -> TecanTipRack: z_dispense=1290.0, z_max=1430.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.0, @@ -248,7 +249,7 @@ def Adapter_96_DiTi_MCA384(name: str) -> TecanTipRack: z_dispense=1422.0, z_max=1461.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -276,7 +277,7 @@ def Adapter_DiTi_Combo_MCA384(name: str) -> TecanTipRack: z_dispense=1422.0, z_max=1457.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=24, num_items_y=16, dx=9.95, @@ -304,7 +305,7 @@ def Adapter_DiTi_MCA384(name: str) -> TecanTipRack: z_dispense=1422.0, z_max=1461.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=24, num_items_y=16, dx=9.95, @@ -332,7 +333,7 @@ def DiTi_100ul_Filter_MCA96(name: str) -> TecanTipRack: z_dispense=1531.0, z_max=1735.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=6.3, @@ -360,7 +361,7 @@ def DiTi_100ul_MCA96(name: str) -> TecanTipRack: z_dispense=1531.0, z_max=1735.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=6.3, @@ -388,7 +389,7 @@ def DiTi_200ul_Filter_MCA96(name: str) -> TecanTipRack: z_dispense=1531.0, z_max=1735.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=6.3, @@ -416,7 +417,7 @@ def DiTi_200ul_MCA96(name: str) -> TecanTipRack: z_dispense=1531.0, z_max=1735.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=6.3, @@ -444,7 +445,7 @@ def DiTi_50ul_MCA96(name: str) -> TecanTipRack: z_dispense=1531.0, z_max=1735.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=6.3, @@ -472,7 +473,7 @@ def Base_Nested_DiTi_MCA96(name: str) -> TecanTipRack: z_dispense=3282.0, z_max=3280.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=4, num_items_y=4, dx=-3.6, @@ -500,7 +501,7 @@ def DiTi_100ul_Nested_MCA96(name: str) -> TecanTipRack: z_dispense=1933.0, z_max=2099.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -527,7 +528,7 @@ def DiTi_100ul_SBS_MCA96(name: str) -> TecanTipRack: z_dispense=1478.0, z_max=1738.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -554,7 +555,7 @@ def DiTi_200ul_SBS_MCA96(name: str) -> TecanTipRack: z_dispense=1478.0, z_max=1738.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -581,7 +582,7 @@ def DiTi_50ul_SBS_MCA96(name: str) -> TecanTipRack: z_dispense=1478.0, z_max=1728.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -609,7 +610,7 @@ def DiTi_50ul_Nested_MCA96(name: str) -> TecanTipRack: z_dispense=1933.0, z_max=2099.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -637,7 +638,7 @@ def Adapter_96_DiTi_1to1_MCA384(name: str) -> TecanTipRack: z_dispense=1422.0, z_max=1461.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.45, @@ -665,7 +666,7 @@ def DiTi_200ul_Nested_MCA96(name: str) -> TecanTipRack: z_dispense=1933.0, z_max=2099.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.0, @@ -693,7 +694,7 @@ def DiTi_200ul_w_b_filter_MCA96(name: str) -> TecanTipRack: z_dispense=1478.0, z_max=1738.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -721,7 +722,7 @@ def DiTi_200ul_wide_bore_MCA96(name: str) -> TecanTipRack: z_dispense=1478.0, z_max=1738.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -749,7 +750,7 @@ def Adapter_96_DiTi_4to1_MCA384(name: str) -> TecanTipRack: z_dispense=1422.0, z_max=1461.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.85, @@ -776,7 +777,7 @@ def DiTi_500ul_Filter_SBS_MCA96(name: str) -> TecanTipRack: z_dispense=1410.0, z_max=1560.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.8, @@ -803,7 +804,7 @@ def DiTi_500ul_SBS_MCA96(name: str) -> TecanTipRack: z_dispense=1438.0, z_max=1578.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.6, @@ -831,7 +832,7 @@ def DiTi_Nested_Waste_MCA384(name: str) -> TecanTipRack: z_dispense=1940.0, z_max=1940.0, area=20385.0, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=29.0, @@ -858,7 +859,7 @@ def DiTi_1000ul_SBS_LiHa(name: str) -> TecanTipRack: z_dispense=1010.0, z_max=1260.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.0, @@ -885,7 +886,7 @@ def DiTi_200ul_SBS_LiHa(name: str) -> TecanTipRack: z_dispense=1360.0, z_max=1660.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -912,7 +913,7 @@ def DiTi_50ul_SBS_LiHa(name: str) -> TecanTipRack: z_dispense=1360.0, z_max=1660.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -940,7 +941,7 @@ def DiTi_5000ul_LiHa(name: str) -> TecanTipRack: z_dispense=850.0, z_max=1150.0, area=50.0, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=6, num_items_y=4, dx=10.5, @@ -968,7 +969,7 @@ def DiTi_5000ul_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=850.0, z_max=1150.0, area=50.0, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=6, num_items_y=4, dx=10.5, @@ -996,7 +997,7 @@ def DiTi_125ul_Filter_MCA384(name: str) -> TecanTipRack: z_dispense=1490.0, z_max=1690.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=24, num_items_y=16, dx=9.65, @@ -1024,7 +1025,7 @@ def DiTi_125ul_MCA384(name: str) -> TecanTipRack: z_dispense=1490.0, z_max=1690.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=24, num_items_y=16, dx=9.85, @@ -1052,7 +1053,7 @@ def DiTi_15ul_Filter_MCA384(name: str) -> TecanTipRack: z_dispense=1676.0, z_max=1879.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=24, num_items_y=16, dx=9.95, @@ -1080,7 +1081,7 @@ def DiTi_15ul_MCA384(name: str) -> TecanTipRack: z_dispense=1676.0, z_max=1879.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=24, num_items_y=16, dx=9.95, @@ -1108,7 +1109,7 @@ def DiTi_50ul_Filter_MCA384(name: str) -> TecanTipRack: z_dispense=1490.0, z_max=1690.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=24, num_items_y=16, dx=9.65, @@ -1136,7 +1137,7 @@ def DiTi_50ul_MCA384(name: str) -> TecanTipRack: z_dispense=1490.0, z_max=1690.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=24, num_items_y=16, dx=9.65, @@ -1164,7 +1165,7 @@ def DiTi_1000ul_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1192,7 +1193,7 @@ def DiTi_1000ul_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1220,7 +1221,7 @@ def DiTi_10ul_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1248,7 +1249,7 @@ def DiTi_10ul_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1276,7 +1277,7 @@ def DiTi_200ul_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1304,7 +1305,7 @@ def DiTi_200ul_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1332,7 +1333,7 @@ def DiTi_50ul_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1360,7 +1361,7 @@ def DiTi_50ul_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1388,7 +1389,7 @@ def DiTi_350ul_Nested_LiHa(name: str) -> TecanTipRack: z_dispense=2015.0, z_max=2175.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=11.2, @@ -1416,7 +1417,7 @@ def DiTi_10ul_Filter_LiHa_L(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1444,7 +1445,7 @@ def DiTi_10ul_Filter_Nested_LiHa(name: str) -> TecanTipRack: z_dispense=2015.0, z_max=2175.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=9.8, @@ -1472,7 +1473,7 @@ def DiTi_10ul_LiHa_L(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1500,7 +1501,7 @@ def DiTi_10ul_Nested_LiHa(name: str) -> TecanTipRack: z_dispense=2015.0, z_max=2175.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=9.8, @@ -1528,7 +1529,7 @@ def DiTi_10ul_SBS_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=1360.0, z_max=1660.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.1, @@ -1556,7 +1557,7 @@ def DiTi_10ul_SBS_LiHa(name: str) -> TecanTipRack: z_dispense=1360.0, z_max=1660.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.5, @@ -1584,7 +1585,7 @@ def DiTi_1000ul_W_B_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1612,7 +1613,7 @@ def DiTi_1000ul_CL_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=9.8, @@ -1640,7 +1641,7 @@ def DiTi_1000ul_CL_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=9.8, @@ -1668,7 +1669,7 @@ def DiTi_200ul_CL_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.7, @@ -1696,7 +1697,7 @@ def DiTi_200ul_CL_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=10.7, @@ -1724,7 +1725,7 @@ def DiTi_50ul_CL_Filter_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, @@ -1752,7 +1753,7 @@ def DiTi_50ul_CL_LiHa(name: str) -> TecanTipRack: z_dispense=865.0, z_max=1087.0, area=33.2, - items=create_equally_spaced(TipSpot, + ordered_items=create_ordered_items_2d(TipSpot, num_items_x=12, num_items_y=8, dx=7.7, diff --git a/pylabrobot/resources/tecan/wash.py b/pylabrobot/resources/tecan/wash.py index 11b9b3d1fb..8ba1c84d7e 100644 --- a/pylabrobot/resources/tecan/wash.py +++ b/pylabrobot/resources/tecan/wash.py @@ -41,7 +41,7 @@ def Wash_Station(name: str) -> TecanWashStation: size_z=0.0, off_x=12.5, off_y=24.7, - sites=create_carrier_sites(locations = [ + sites=create_carrier_sites(klass=CarrierSite, locations=[ Coordinate(12.2, 106.7, 0.0), Coordinate(11.0, 180.7, 0.0), Coordinate(12.2, 281.7, 0.0), diff --git a/pylabrobot/resources/thermo_fisher/README.md b/pylabrobot/resources/thermo_fisher/README.md deleted file mode 100644 index 6e04d3df80..0000000000 --- a/pylabrobot/resources/thermo_fisher/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Resource definitions: Thermo Fisher - -## Troughs - -| Description | Image | PLR definition | -|--------------------|--------------------|--------------------| -| 'ThermoFisherMatrixTrough8094'
Part no.: 8094
[manufacturer website](https://www.thermofisher.com/order/catalog/product/8094) | ThermoFisherMatrixTrough8094.jpg.avif | `ThermoFisherMatrixTrough8094` | diff --git a/pylabrobot/resources/thermo_fisher/__init__.py b/pylabrobot/resources/thermo_fisher/__init__.py index ef151f9f3c..008b938ef8 100644 --- a/pylabrobot/resources/thermo_fisher/__init__.py +++ b/pylabrobot/resources/thermo_fisher/__init__.py @@ -1 +1,2 @@ +from .plates import * from .troughs import * diff --git a/pylabrobot/resources/thermo_fisher/plates.py b/pylabrobot/resources/thermo_fisher/plates.py index 4b2214ffa4..b9fe07aa67 100644 --- a/pylabrobot/resources/thermo_fisher/plates.py +++ b/pylabrobot/resources/thermo_fisher/plates.py @@ -101,14 +101,14 @@ def Thermo_TS_96_wellplate_1200ul_Rb(name: str, with_lid: bool = False) -> Plate num_items_y=8, dx=10.0, dy=7.3, - dz=1.0, # 2.5. https://github.com/PyLabRobot/pylabrobot/pull/183 + dz=2.5, # 2.5. https://github.com/PyLabRobot/pylabrobot/pull/183 item_dx=9, item_dy=9, size_x=8.3, size_y=8.3, size_z=20.5, bottom_type=WellBottomType.U, - material_z_thickness=1.0, + material_z_thickness=1.15, cross_section_type=CrossSectionType.RECTANGLE, compute_volume_from_height=( _compute_volume_from_height_Thermo_TS_96_wellplate_1200ul_Rb diff --git a/pylabrobot/resources/tip_rack.py b/pylabrobot/resources/tip_rack.py index 7329c6433b..4faec75f60 100644 --- a/pylabrobot/resources/tip_rack.py +++ b/pylabrobot/resources/tip_rack.py @@ -1,10 +1,9 @@ -""" Abstract base class for tip rack resources. """ - from __future__ import annotations from abc import ABCMeta from typing import Any, Dict, List, Union, Optional, Sequence, cast +from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.tip import Tip, TipCreator from pylabrobot.resources.tip_tracker import TipTracker, does_tip_tracking from pylabrobot.serializer import deserialize @@ -64,11 +63,11 @@ def serialize(self) -> dict: } @classmethod - def deserialize(cls, data: dict) -> TipSpot: + def deserialize(cls, data: dict, allow_marshal: bool = False) -> TipSpot: """ Deserialize a tip spot. """ tip_data = data["prototype_tip"] def make_tip() -> Tip: - return cast(Tip, deserialize(tip_data)) + return cast(Tip, deserialize(tip_data, allow_marshal=allow_marshal)) return cls( name=data["name"], @@ -87,7 +86,7 @@ def load_state(self, state: Dict[str, Any]): class TipRack(ItemizedResource[TipSpot], metaclass=ABCMeta): - """ Abstract base class for Tips resources. """ + """ Tip rack for disposable tips. """ def __init__( self, @@ -95,17 +94,16 @@ def __init__( size_x: float, size_y: float, size_z: float, - items: Optional[List[List[TipSpot]]] = None, - num_items_x: Optional[int] = None, - num_items_y: Optional[int] = None, + ordered_items: Optional[Dict[str, TipSpot]] = None, + ordering: Optional[List[str]] = None, category: str = "tip_rack", model: Optional[str] = None, with_tips: bool = True, ): - super().__init__(name, size_x, size_y, size_z, items=items, num_items_x=num_items_x, - num_items_y=num_items_y, category=category, model=model) + super().__init__(name, size_x, size_y, size_z, ordered_items=ordered_items, ordering=ordering, + category=category, model=model) - if items is not None and len(items) > 0: + if ordered_items is not None and len(ordered_items) > 0: if with_tips: self.fill() else: @@ -115,6 +113,10 @@ def __repr__(self) -> str: return (f"{self.__class__.__name__}(name={self.name}, size_x={self._size_x}, " f"size_y={self._size_y}, size_z={self._size_z}, location={self.location})") + @staticmethod + def _occupied_func(item: TipSpot): + return "V" if item.has_tip() else "-" + def get_tip(self, identifier: Union[str, int]) -> Tip: """ Get the item with the given identifier. @@ -131,23 +133,31 @@ def get_tips(self, identifier: Union[str, Sequence[int]]) -> List[Tip]: return [ts.get_tip() for ts in super().get_items(identifier)] - def set_tip_state(self, tips: List[List[bool]]) -> None: + def set_tip_state(self, tips: Union[List[bool], Dict[str, bool]]) -> None: """ Set the initial tip tracking state of all tips in this tip rack. + Args: + tips: either a list of booleans (using integer indexing) or a dictionary of booleans (using + string indexing) for whether each tip should be filled or empty. + Examples: Filling the right half of a 96-well tip rack: >>> tip_rack.set_tip_state([[True] * 6 + [False] * 6] * 8) """ - for i in range(self.num_items_y): - for j in range(self.num_items_x): - # If the tip state is different from the current state, update it by either creating or - # removing the tip. - if tips[i][j] and not self.get_item((i, j)).has_tip(): - self.get_item((i, j)).tracker.add_tip(self.get_item((i, j)).make_tip(), commit=True) - elif not tips[i][j] and self.get_item((i, j)).has_tip(): - self.get_item((i, j)).tracker.remove_tip(commit=True) + should_have: Dict[Union[int, str], bool] = {} + if isinstance(tips, list): + for i, tip in enumerate(tips): + should_have[i] = tip + else: + should_have = cast(Dict[Union[int, str], bool], tips) # type? + + for identifier, should_have_tip in should_have.items(): + if should_have_tip and not self.get_item(identifier).has_tip(): + self.get_item(identifier).tracker.add_tip(self.get_item(identifier).make_tip(), commit=True) + elif not should_have_tip and self.get_item(identifier).has_tip(): + self.get_item(identifier).tracker.remove_tip(commit=True) def disable_tip_trackers(self) -> None: """ Disable tip tracking for all tips in this tip rack. """ @@ -162,13 +172,62 @@ def enable_tip_trackers(self) -> None: def empty(self): """ Empty the tip rack. This is useful when tip tracking is enabled and you are modifying the state manually (without the robot). """ - self.set_tip_state([[False] * self.num_items_x] * self.num_items_y) + self.set_tip_state([False] * self.num_items) def fill(self): """ Fill the tip rack. This is useful when tip tracking is enabled and you are modifying the state manually (without the robot). """ - self.set_tip_state([[True] * self.num_items_x] * self.num_items_y) + self.set_tip_state([True] * self.num_items) def get_all_tips(self) -> List[Tip]: """ Get all tips in the tip rack. """ return [ts.get_tip() for ts in self.get_all_items()] + + +class NestedTipRack(TipRack): + """ A nested tip rack. """ + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + stacking_z_height: float, + ordered_items: Optional[Dict[str, TipSpot]] = None, + ordering: Optional[List[str]] = None, + category: str = "tip_rack", + model: Optional[str] = None, + with_tips: bool = True, + ): + # Call the superclass constructor + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + ordered_items=ordered_items, + ordering=ordering, + category=category, + model=model, + with_tips=with_tips + ) + + self.stacking_z_height = stacking_z_height + + def __repr__(self) -> str: + return (f"{self.__class__.__name__}(name={self.name}, size_x={self._size_x}, " + f"size_y={self._size_y}, size_z={self._size_z}, " + f"stacking_z_height={self.stacking_z_height}, location={self.location})") + + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True + ): + if isinstance(resource, NestedTipRack): + location = location or Coordinate(0, 0, self.stacking_z_height) + else: + assert location is not None, "Location must be specified if " + \ + "resource is not a NestedTipRack." + return super().assign_child_resource(resource, location=location, reassign=reassign) diff --git a/pylabrobot/resources/trough.py b/pylabrobot/resources/trough.py index e71af3739a..129643a552 100644 --- a/pylabrobot/resources/trough.py +++ b/pylabrobot/resources/trough.py @@ -1,7 +1,15 @@ -from typing import Optional +import enum +from typing import Callable, Optional, Union from .container import Container +class TroughBottomType(enum.Enum): + """ Enum for the type of bottom of a trough. """ + + FLAT = "flat" + U = "U" + V = "V" + UNKNOWN = "unknown" class Trough(Container): """ A trough is a container, particularly useful for multichannel liquid handling operations. """ @@ -13,15 +21,29 @@ def __init__( size_y: float, size_z: float, max_volume: float, + material_z_thickness: Optional[float] = None, + through_base_to_container_base: float = 0, category: Optional[str] = "trough", - model: Optional[str] = None + model: Optional[str] = None, + bottom_type: Union[TroughBottomType, str] = TroughBottomType.UNKNOWN, + compute_volume_from_height: Optional[Callable[[float], float]] = None, + compute_height_from_volume: Optional[Callable[[float], float]] = None, ): + + if isinstance(bottom_type, str): + bottom_type = TroughBottomType(bottom_type) + super().__init__( name=name, size_x=size_x, size_y=size_y, size_z=size_z, + material_z_thickness=material_z_thickness, max_volume=max_volume, category=category, - model=model + model=model, + compute_volume_from_height=compute_volume_from_height, + compute_height_from_volume=compute_height_from_volume ) + self.through_base_to_container_base = through_base_to_container_base + self.bottom_type = bottom_type diff --git a/pylabrobot/resources/tube.py b/pylabrobot/resources/tube.py index 02782fa92f..109019c168 100644 --- a/pylabrobot/resources/tube.py +++ b/pylabrobot/resources/tube.py @@ -1,19 +1,57 @@ -from typing import Optional +from typing import List, Optional, Tuple from pylabrobot.resources.container import Container - +from pylabrobot.resources.liquid import Liquid class Tube(Container): - """ Tube container, like Eppendorf tubes. """ + """ Tube container, like Eppendorf tubes. + + Note that in regular use these will be automatically generated by the + :class:`pylabrobot.resources.TubeRack` class. + """ + + def __init__(self, name: str, size_x: float, size_y: float, size_z: float, max_volume: float, + material_z_thickness: Optional[float] = None, category: str = "tube", + model: Optional[str] = None): + """ Create a new tube. + + Args: + name: Name of the tube. + size_x: Size of the tube in the x direction. + size_y: Size of the tube in the y direction. + size_z: Size of the tube in the z direction. + material_z_thickness: Tube base to cavity base. + max_volume: Maximum volume of the tube. + category: Category of the tube. + """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - category: str = "tube", max_volume: Optional[float] = None, model: Optional[str] = None): super().__init__( name=name, size_x=size_x, size_y=size_y, size_z=size_z, + material_z_thickness=material_z_thickness, category=category, max_volume=max_volume, model=model ) + self.tracker.register_callback(self._state_updated) + + def serialize(self) -> dict: + return { + **super().serialize(), + "max_volume": self.max_volume + } + + def set_liquids(self, liquids: List[Tuple[Optional["Liquid"], float]]): + """ Set the liquids in the tube. + + (wraps :meth:`~.VolumeTracker.set_liquids`) + + Example: + Set the liquids in a tube to 10 uL of water: + + >>> tube.set_liquids([(Liquid.WATER, 10)]) + """ + + self.tracker.set_liquids(liquids) diff --git a/pylabrobot/resources/tube_rack.py b/pylabrobot/resources/tube_rack.py index 22ef45e013..d42d96b830 100644 --- a/pylabrobot/resources/tube_rack.py +++ b/pylabrobot/resources/tube_rack.py @@ -1,8 +1,11 @@ -from typing import List, Optional +from typing import Dict, List, Optional, Sequence, Tuple, Union, cast from pylabrobot.resources.itemized_resource import ItemizedResource from pylabrobot.resources.tube import Tube +from .itemized_resource import ItemizedResource +from .resource import Resource, Coordinate +from .liquid import Liquid class TubeRack(ItemizedResource[Tube]): """ Tube rack resource. """ @@ -13,7 +16,7 @@ def __init__( size_x: float, size_y: float, size_z: float, - items: List[List[Tube]], + ordered_items: Optional[Dict[str, Tube]] = None, model: Optional[str] = None, ): """ Initialize a TubeRack resource. @@ -31,17 +34,89 @@ def __init__( size_x=size_x, size_y=size_y, size_z=size_z, - items=items, + ordered_items=ordered_items, model=model) + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True + ): + assert location is not None, "Location must be specified for resource." + return super().assign_child_resource(resource, location=location, reassign=reassign) + + def __repr__(self) -> str: + return (f"{self.__class__.__name__}(name={self.name}, size_x={self._size_x}, " + f"size_y={self._size_y}, size_z={self._size_z}, location={self.location})") + + def get_tube(self, identifier: Union[str, int, Tuple[int, int]]) -> Tube: + """ Get the item with the given identifier. + + See :meth:`~.get_item` for more information. + """ + + return super().get_item(identifier) + + def get_tubes(self, + identifier: Union[str, Sequence[int]]) -> List[Tube]: + """ Get the tubes with the given identifier. + + See :meth:`~.get_items` for more information. + """ + + return super().get_items(identifier) + + def set_tube_liquids( + self, + liquids: Union[ + List[List[Tuple[Optional["Liquid"], Union[int, float]]]], + List[Tuple[Optional["Liquid"], Union[int, float]]], + Tuple[Optional["Liquid"], Union[int, float]]] + ) -> None: + + """ Update the liquid in the volume tracker for each tube in the rack. + + Args: + liquids: A list of liquids, one for each tube in the rack. The list can be a list of lists, + where each inner list contains the liquids for each tube in a column. If a single tuple is + given, the volume is assumed to be the same for all tubes. Liquids are in uL. + + Raises: + ValueError: If the number of liquids does not match the number of tubes in the rack. + + Example: + Set the volume of each tube in a 4x6 rack to 1000 uL. + + >>> rack = TubeRack("rack", 127.76, 85.48, 14.5, num_items_x=6, num_items_y=4) + >>> rack.set_tube_liquids((Liquid.WATER, 1000)) + """ + + if isinstance(liquids, tuple): + liquids = [liquids] * self.num_items + elif isinstance(liquids, list) and all(isinstance(column, list) for column in liquids): + # mypy doesn't know that all() checks the type + liquids = cast(List[List[Tuple[Optional["Liquid"], float]]], liquids) + liquids = [list(column) for column in zip(*liquids)] # transpose the list of lists + liquids = [volume for column in liquids for volume in column] # flatten the list of lists + + if len(liquids) != self.num_items: + raise ValueError(f"Number of liquids ({len(liquids)}) does not match number of tubes " + f"({self.num_items}) in rack '{self.name}'.") + + for i, (liquid, volume) in enumerate(liquids): + tube = self.get_tube(i) + tube.tracker.set_liquids([(liquid, volume)]) # type: ignore + + def disable_volume_trackers(self) -> None: - """ Disable volume tracking for all wells in the plate. """ + """ Disable volume tracking for all tubes in the rack. """ for tube in self.get_all_items(): tube.tracker.disable() def enable_volume_trackers(self) -> None: - """ Enable volume tracking for all wells in the plate. """ + """ Enable volume tracking for all tubes in the rack. """ for tube in self.get_all_items(): tube.tracker.enable() diff --git a/pylabrobot/resources/utils.py b/pylabrobot/resources/utils.py index 066288ceba..a88b67d418 100644 --- a/pylabrobot/resources/utils.py +++ b/pylabrobot/resources/utils.py @@ -1,5 +1,6 @@ +import re from string import ascii_uppercase as LETTERS -from typing import Dict, List, Type, TypeVar +from typing import Dict, List, Optional, Type, TypeVar from pylabrobot.resources.coordinate import Coordinate from pylabrobot.resources.resource import Resource @@ -22,7 +23,7 @@ def create_equally_spaced_2d( num_items_x: The number of items in the x direction num_items_y: The number of items in the y direction dx: The bottom left corner for items in the left column - dy: The bottom left corner for items in the top row + dy: The bottom left corner for items in the bottom row dz: The z coordinate for all items item_dx: The size of the items in the x direction item_dy: The size of the items in the y direction @@ -64,7 +65,7 @@ def create_equally_spaced_x( klass: The class of the resource to create num_items_x: The number of items in the x direction dx: The bottom left corner for items in the left column - dy: The bottom left corner for items in the top row + dy: The bottom left corner for items in the bottom row dz: The z coordinate for all items item_dx: The size of the items in the x direction **kwargs: Additional keyword arguments to pass to the resource constructor @@ -98,7 +99,7 @@ def create_equally_spaced_y( klass: The class of the resource to create num_items_y: The number of items in the y direction dx: The bottom left corner for items in the left column - dy: The bottom left corner for items in the top row + dy: The bottom left corner for items in the bottom row dz: The z coordinate for all items item_dy: The size of the items in the y direction **kwargs: Additional keyword arguments to pass to the resource constructor @@ -133,7 +134,7 @@ def create_ordered_items_2d( num_items_x: The number of items in the x direction num_items_y: The number of items in the y direction dx: The bottom left corner for items in the left column - dy: The bottom left corner for items in the top row + dy: The bottom left corner for items in the bottom row dz: The z coordinate for all items item_dx: The size of the items in the x direction item_dy: The size of the items in the y direction @@ -154,3 +155,47 @@ def create_ordered_items_2d( ) keys = [f"{LETTERS[j]}{i+1}" for i in range(num_items_x) for j in range(num_items_y)] return dict(zip(keys, [item for sublist in items for item in sublist])) + + +U = TypeVar("U", bound=Resource) +def query( + root: Resource, + type_: Type[U] = Resource, # type: ignore + name: Optional[str] = None, + x: Optional[float] = None, + y: Optional[float] = None, + z: Optional[float] = None, +) -> List[U]: + """ Query resources based on their attributes. + + Args: + root: The root resource to search + type_: The type of resources to search for + name: The regular expression to match the name of the resources + x: The x-coordinate of the resources + y: The y-coordinate of the resources + z: The z-coordinate of the resources + """ + matched: List[U] = [] + for resource in root.children: + if type_ is not None and not isinstance(resource, type_): + continue + if name is not None and not re.match(name, resource.name): + continue + if x is not None and (resource.location is None or resource.location.x != x): + continue + if y is not None and (resource.location is None or resource.location.y != y): + continue + if z is not None and (resource.location is None or resource.location.z != z): + continue + matched.append(resource) + + matched.extend(query( + root=resource, + type_=type_, + name=name, + x=x, + y=y, + z=z, + )) + return matched diff --git a/pylabrobot/resources/utils_tests.py b/pylabrobot/resources/utils_tests.py new file mode 100644 index 0000000000..b6570f3a01 --- /dev/null +++ b/pylabrobot/resources/utils_tests.py @@ -0,0 +1,37 @@ +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.utils import query +from pylabrobot.resources.well import Well + + +def test_query(): + root = Resource(name="root", size_x=10, size_y=10, size_z=10) + child1 = Resource(name="child1", size_x=5, size_y=5, size_z=5) + child2 = Resource(name="child2", size_x=5, size_y=5, size_z=5) + child3 = Resource(name="odd", size_x=5, size_y=5, size_z=5) + root.assign_child_resource(child1, location=Coordinate(0, 0, 0)) + root.assign_child_resource(child2, location=Coordinate(5, 0, 0)) + root.assign_child_resource(child3, location=Coordinate(0, 5, 0)) + + assert query(root, Resource) == [child1, child2, child3] + assert query(root, Resource, x=0) == [child1, child3] + assert query(root, Resource, y=0) == [child1, child2] + assert query(root, name=r"child\d") == [child1, child2] + +def test_query_with_type(): + root = Resource(name="root", size_x=10, size_y=10, size_z=10) + well1 = Well(name="well", size_x=3, size_y=3, size_z=3) + well2 = Well(name="well", size_x=3, size_y=3, size_z=3) + root.assign_child_resource(well1, location=Coordinate(6, 1, 0)) + root.assign_child_resource(well2, location=Coordinate(6, 6, 0)) + assert query(root, Well) == [well1, well2] + assert query(root, Well, x=6) == [well1, well2] + +def test_deep(): + root = Resource(name="root", size_x=10, size_y=10, size_z=10) + child1 = Resource(name="child1", size_x=5, size_y=5, size_z=5) + grandchild = Resource(name="grandchild", size_x=2, size_y=2, size_z=2) + root.assign_child_resource(child1, location=Coordinate(0, 0, 0)) + child1.assign_child_resource(grandchild, location=Coordinate(1, 1, 0)) + + assert query(root, Resource) == [child1, grandchild] diff --git a/pylabrobot/resources/volume_functions.py b/pylabrobot/resources/volume_functions.py index eabbbff73a..cfc4835d17 100644 --- a/pylabrobot/resources/volume_functions.py +++ b/pylabrobot/resources/volume_functions.py @@ -1,8 +1,3 @@ -# General use functions for calculating the volume of different container geometries - -import math - - def calculate_liquid_volume_container_2segments_square_vbottom( x: float, y: float, @@ -11,48 +6,11 @@ def calculate_liquid_volume_container_2segments_square_vbottom( liquid_height: float ) -> float: """ - Calculate the volume of liquid in a container consisting of an upside-down - square pyramid at the bottom and a cuboid on top. The container has the - same x and y dimensions for both the pyramid and the cuboid. - - The function calculates the volume based on whether the liquid height is within - the pyramid or extends into the cuboid. - - Parameters: - x (float): The base length of the square pyramid and cube in mm. - y (float): The base width of the square pyramid and cube in mm. - h_pyramid (float): The height of the square pyramid in mm. - h_cube (float): The height of the cube in mm. - liquid_height (float): The height of the liquid in the container in mm. - - Returns: - float: The volume of the liquid in cubic millimeters. + This function is deprecated. Please use the equivalent function in + src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - if liquid_height > h_pyramid + h_cube: - raise ValueError("""WARNING: Liquid overflow detected; - check your labware definiton and/or that you are using the right labware.""") - - # Calculating the base area - base_area = x * y - - # Calculating the full volume of the pyramid - full_pyramid_volume = (1/3) * base_area * h_pyramid - - if liquid_height <= h_pyramid: - # Liquid height is within the pyramid - # Calculating the scale factor for the reduced height - scale_factor = liquid_height / h_pyramid - # Calculating the sub-volume of the pyramid - liquid_volume = full_pyramid_volume * (scale_factor ** 3) - else: - # Liquid height extends into the cube - # Calculating the volume of the cube portion filled with liquid - cube_liquid_height = liquid_height - h_pyramid - cube_liquid_volume = base_area * cube_liquid_height - # Total liquid volume is the sum of the pyramid and cube volumes - liquid_volume = full_pyramid_volume + cube_liquid_volume - - return float(liquid_volume) + raise DeprecationWarning("This function is deprecated. Please use the equivalent function in " + "src/pylabrobot/pylabrobot/resources/height_volume_functions.py") def calculate_liquid_volume_container_2segments_square_ubottom( @@ -61,40 +19,11 @@ def calculate_liquid_volume_container_2segments_square_ubottom( liquid_height: float ) -> float: """ - Calculate the volume of liquid in a container with a hemispherical bottom and a cuboidal top. - The diameter of the hemisphere is equal to the side length of the square base of the cuboid. - - The function calculates the volume based on whether the liquid height is within the hemisphere or - extends into the cuboid. - - Parameters: - x: The side length of the square base of the cuboid and diameter of the hemisphere in mm. - h_cuboid: The height of the cuboid in mm. - liquid_height: The height of the liquid in the container in mm. - - Returns: - The volume of the liquid in cubic millimeters. + This function is deprecated. Please use the equivalent function in + src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - if liquid_height > h_cuboid + x/2: - raise ValueError("""WARNING: Liquid overflow detected; - check your labware definiton and/or that you are using the right labware.""") - - r = x / 2 # Radius of the hemisphere - full_hemisphere_volume = (2/3) * math.pi * r**3 - - if liquid_height <= r: - # Liquid height is within the hemisphere - # Calculating the sub-volume of the hemisphere using spherical cap volume formula - h = liquid_height # Height of the spherical cap - liquid_volume = (1/3) * math.pi * h**2 * (3*r - h) - else: - # Liquid height extends into the cuboid - # Calculating the volume of the cuboid portion filled with liquid - cuboid_liquid_height = liquid_height - r - cuboid_liquid_volume = x**2 * cuboid_liquid_height - liquid_volume = full_hemisphere_volume + cuboid_liquid_volume - - return liquid_volume + raise DeprecationWarning("This function is deprecated. Please use the equivalent function in " + "src/pylabrobot/pylabrobot/resources/height_volume_functions.py") def calculate_liquid_volume_container_2segments_round_vbottom( @@ -104,45 +33,11 @@ def calculate_liquid_volume_container_2segments_round_vbottom( liquid_height: float ) -> float: """ - Calculate the volume of liquid in a container with a conical bottom and - a cylindrical top. The container has the same radius for both the cone and - the cylinder. - - The function calculates the volume based on whether the liquid height is - within the cone or extends into the cylinder. - - Parameters: - d (float): The diameter of the base of the cone and cylinder in mm. - h_cone (float): The height of the cone in mm. - h_cylinder (float): The height of the cylinder in mm. - liquid_height (float): The height of the liquid in the container in mm. - - Returns: - float: The volume of the liquid in cubic millimeters. + This function is deprecated. Please use the equivalent function in + src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - if liquid_height > h_cone+h_cylinder: - raise ValueError("""WARNING: Liquid overflow detected; - check your labware definiton and/or that you are using the right labware.""") - - r = d/2 - # Calculating the full volume of the cone - full_cone_volume = (1/3) * math.pi * r**2 * h_cone - - if liquid_height <= h_cone: - # Liquid height is within the cone - # Calculating the scale factor for the reduced height - scale_factor = liquid_height / h_cone - # Calculating the sub-volume of the cone - liquid_volume = full_cone_volume * (scale_factor ** 3) - else: - # Liquid height extends into the cylinder - # Calculating the volume of the cylinder portion filled with liquid - cylinder_liquid_height = liquid_height - h_cone - cylinder_liquid_volume = math.pi * r**2 * cylinder_liquid_height - # Total liquid volume is the sum of the cone and cylinder volumes - liquid_volume = full_cone_volume + cylinder_liquid_volume - - return float(liquid_volume) + raise DeprecationWarning("This function is deprecated. Please use the equivalent function in " + "src/pylabrobot/pylabrobot/resources/height_volume_functions.py") def calculate_liquid_volume_container_2segments_round_ubottom( @@ -151,40 +46,21 @@ def calculate_liquid_volume_container_2segments_round_ubottom( liquid_height: float ) -> float: """ - Calculate the volume of liquid in a container with a hemispherical bottom - and a cylindrical top. The container has the same radius for both the - hemisphere and the cylinder. - - The function calculates the volume based on whether the liquid height is - within the hemisphere or extends into the cylinder. - - Parameters: - d (float): The diameter of the base of the hemisphere and cylinder in mm. - h_cylinder (float): The height of the cylinder in mm. - liquid_height (float): The height of the liquid in the container in mm. - - Returns: - float: The volume of the liquid in cubic millimeters. + This function is deprecated. Please use the equivalent function in + src/pylabrobot/pylabrobot/resources/height_volume_functions.py """ - r = d/2 - if liquid_height > h_cylinder+r: - raise ValueError("""WARNING: Liquid overflow detected; - check your labware definiton and/or that you are using the right labware.""") - - # Calculating the full volume of the hemisphere - full_hemisphere_volume = (2/3) * math.pi * r**3 + raise DeprecationWarning("This function is deprecated. Please use the equivalent function in " + "src/pylabrobot/pylabrobot/resources/height_volume_functions.py") - if liquid_height <= r: - # Liquid height is within the hemisphere - # Calculating the sub-volume of the hemisphere using spherical cap volume formula - h = liquid_height # Height of the spherical cap - liquid_volume = (1/3) * math.pi * h**2 * (3*r - h) - else: - # Liquid height extends into the cylinder - # Calculating the volume of the cylinder portion filled with liquid - cylinder_liquid_height = liquid_height - r - cylinder_liquid_volume = math.pi * r**2 * cylinder_liquid_height - # Total liquid volume is the sum of the hemisphere and cylinder volumes - liquid_volume = full_hemisphere_volume + cylinder_liquid_volume - return float(liquid_volume) +def calculate_liquid_volume_container_1segment_round_fbottom( + d: float, + h_cylinder: float, + liquid_height: float +) -> float: + """ + This function is deprecated. Please use the equivalent function in + src/pylabrobot/pylabrobot/resources/height_volume_functions.py + """ + raise DeprecationWarning("This function is deprecated. Please use the equivalent function in " + "src/pylabrobot/pylabrobot/resources/height_volume_functions.py") diff --git a/pylabrobot/resources/volume_functions_tests.py b/pylabrobot/resources/volume_functions_tests.py index 557696b5f3..8766e390a2 100644 --- a/pylabrobot/resources/volume_functions_tests.py +++ b/pylabrobot/resources/volume_functions_tests.py @@ -1,7 +1,7 @@ import math import unittest -from pylabrobot.resources.volume_functions import ( +from pylabrobot.resources.height_volume_functions import ( calculate_liquid_volume_container_2segments_square_vbottom, calculate_liquid_volume_container_2segments_round_vbottom, calculate_liquid_volume_container_2segments_round_ubottom, diff --git a/pylabrobot/resources/volume_tracker.py b/pylabrobot/resources/volume_tracker.py index ce5ace5de9..a37e82589c 100644 --- a/pylabrobot/resources/volume_tracker.py +++ b/pylabrobot/resources/volume_tracker.py @@ -10,20 +10,40 @@ this = sys.modules[__name__] this.volume_tracking_enabled = False # type: ignore +this.cross_contamination_tracking_enabled = False # type: ignore def set_volume_tracking(enabled: bool): this.volume_tracking_enabled = enabled # type: ignore + if not enabled: + this.cross_contamination_tracking_enabled = False # type: ignore def does_volume_tracking() -> bool: return this.volume_tracking_enabled # type: ignore @contextlib.contextmanager def no_volume_tracking(): - old_value = this.volume_tracking_enabled + vt, ct = this.volume_tracking_enabled, this.cross_contamination_tracking_enabled # type: ignore this.volume_tracking_enabled = False # type: ignore + this.cross_contamination_tracking_enabled = False # type: ignore yield - this.volume_tracking_enabled = old_value # type: ignore + this.volume_tracking_enabled = vt # type: ignore + this.cross_contamination_tracking_enabled = ct # type: ignore +def set_cross_contamination_tracking(enabled: bool): + if enabled: + assert this.volume_tracking_enabled, \ + "Cross contamination tracking only possible if volume tracking active." + this.cross_contamination_tracking_enabled = enabled # type: ignore + +def does_cross_contamination_tracking() -> bool: + return this.cross_contamination_tracking_enabled # type: ignore + +@contextlib.contextmanager +def no_cross_contamination_tracking(): + old_value = this.cross_contamination_tracking_enabled + this.cross_contamination_tracking_enabled = False # type: ignore + yield + this.cross_contamination_tracking_enabled = old_value # type: ignore VolumeTrackerCallback = Callable[[], None] @@ -36,32 +56,52 @@ def __init__( self, max_volume: float, liquids: Optional[List[Tuple[Optional[Liquid], float]]] = None, - pending_liquids: Optional[List[Tuple[Optional[Liquid], float]]] = None + pending_liquids: Optional[List[Tuple[Optional[Liquid], float]]] = None, + liquid_history: Optional[set] = None ) -> None: self._is_disabled = False + self._is_cross_contamination_tracking_disabled = False self.max_volume = max_volume self.liquids: List[Tuple[Optional[Liquid], float]] = liquids or [] self.pending_liquids: List[Tuple[Optional[Liquid], float]] = pending_liquids or [] + self.liquid_history = {liquid for liquid in (liquid_history or set()) if liquid is not None} + self._callback: Optional[VolumeTrackerCallback] = None @property def is_disabled(self) -> bool: return self._is_disabled + @property + def is_cross_contamination_tracking_disabled(self) -> bool: + return self._is_cross_contamination_tracking_disabled + def disable(self) -> None: """ Disable the volume tracker. """ self._is_disabled = True + def disable_cross_contamination_tracking(self) -> None: + """ Disable the cross contamination tracker. """ + self._is_cross_contamination_tracking_disabled = True + def enable(self) -> None: """ Enable the volume tracker. """ self._is_disabled = False + def enable_cross_contamination_tracking(self) -> None: + """ Enable the cross contamination tracker. """ + self._is_cross_contamination_tracking_disabled = False + def set_liquids(self, liquids: List[Tuple[Optional["Liquid"], float]]) -> None: """ Set the liquids in the container. """ self.liquids = liquids self.pending_liquids = liquids + + if not self.is_cross_contamination_tracking_disabled: + self.liquid_history.update([l[0] for l in liquids]) + if self._callback is not None: self._callback() @@ -85,6 +125,9 @@ def remove_liquid(self, volume: float) -> List[Tuple[Optional["Liquid"], float]] else: removed_liquids.append((liquid, liquid_volume)) + if self._callback is not None: + self._callback() + return removed_liquids def add_liquid(self, liquid: Optional["Liquid"], volume: float) -> None: @@ -94,6 +137,11 @@ def add_liquid(self, liquid: Optional["Liquid"], volume: float) -> None: raise TooLittleVolumeError( f"Container has too little volume: {volume}uL > {self.get_free_volume()}uL.") + # Update the liquid history tracker if needed + if not self.is_cross_contamination_tracking_disabled: + if liquid is not None: + self.liquid_history.add(liquid) + # If the last liquid is the same as the one we want to add, just add the volume to it. if len(self.pending_liquids) > 0: last_pending_liquid_tuple = self.pending_liquids[-1] @@ -104,6 +152,9 @@ def add_liquid(self, liquid: Optional["Liquid"], volume: float) -> None: else: self.pending_liquids.append((liquid, volume)) + if self._callback is not None: + self._callback() + def get_used_volume(self) -> float: """ Get the used volume of the container. Note that this includes pending operations. """ return sum(volume for _, volume in self.pending_liquids) @@ -146,9 +197,21 @@ def rollback(self) -> None: assert not self.is_disabled, "Volume tracker is disabled. Call `enable()`." self.pending_liquids.clear() + def clear_cross_contamination_history(self) -> None: + """ Resets the liquid_history for cross contamination tracking. Use when there is a wash step. + """ + self.liquid_history.clear() + def serialize(self) -> dict: """ Serialize the volume tracker. """ + if not self.is_cross_contamination_tracking_disabled: + return { + "liquids": [serialize(l) for l in self.liquids], + "pending_liquids": [serialize(l) for l in self.pending_liquids], + "liquid_history": [serialize(l) for l in self.liquid_history], + } + return { "liquids": [serialize(l) for l in self.liquids], "pending_liquids": [serialize(l) for l in self.pending_liquids], @@ -163,5 +226,8 @@ def load_liquid(data) -> Tuple[Optional["Liquid"], float]: self.liquids = [load_liquid(l) for l in state["liquids"]] self.pending_liquids = [load_liquid(l) for l in state["pending_liquids"]] + if not self.is_cross_contamination_tracking_disabled: + self.liquid_history = set(state["liquid_history"]) + def register_callback(self, callback: VolumeTrackerCallback) -> None: self._callback = callback diff --git a/pylabrobot/resources/well.py b/pylabrobot/resources/well.py index 7e66994cae..ddbfbd1de3 100644 --- a/pylabrobot/resources/well.py +++ b/pylabrobot/resources/well.py @@ -34,10 +34,16 @@ class Well(Container): :class:`pylabrobot.resources.Plate` class. """ - def __init__(self, name: str, size_x: float, size_y: float, size_z: float, - bottom_type: Union[WellBottomType, str] = WellBottomType.UNKNOWN, category: str = "well", - max_volume: Optional[float] = None, model: Optional[str] = None, + def __init__( + self, + name: str, + size_x: float, size_y: float, size_z: float, + material_z_thickness: Optional[float] = None, + bottom_type: Union[WellBottomType, str] = WellBottomType.UNKNOWN, + category: str = "well", model: Optional[str] = None, + max_volume: Optional[float] = None, compute_volume_from_height: Optional[Callable[[float], float]] = None, + compute_height_from_volume: Optional[Callable[[float], float]] = None, cross_section_type: Union[CrossSectionType, str] = CrossSectionType.CIRCLE): """ Create a new well. @@ -74,9 +80,10 @@ def __init__(self, name: str, size_x: float, size_y: float, size_z: float, max_volume = compute_volume_from_height(size_z) super().__init__(name, size_x=size_x, size_y=size_y, size_z=size_z, category=category, - max_volume=max_volume, model=model) + max_volume=max_volume, model=model, compute_volume_from_height=compute_volume_from_height, + compute_height_from_volume=compute_height_from_volume, + material_z_thickness=material_z_thickness) self.bottom_type = bottom_type - self._compute_volume_from_height = compute_volume_from_height self.cross_section_type = cross_section_type self.tracker.register_callback(self._state_updated) @@ -88,25 +95,6 @@ def serialize(self): "cross_section_type": self.cross_section_type.value, } - def compute_volume_from_height(self, height: float) -> float: - """ Compute the volume of liquid in a well from the height of the liquid relative to the bottom - of the well. - - Args: - height: Height of the liquid in the well relative to the bottom. - - Returns: - The volume of liquid in the well. - - Raises: - NotImplementedError: If the plate does not have a volume computation function. - """ - - if self._compute_volume_from_height is None: - raise NotImplementedError("compute_volume_from_height not implemented.") - - return self._compute_volume_from_height(height) - def set_liquids(self, liquids: List[Tuple[Optional["Liquid"], float]]): """ Set the liquids in the well. diff --git a/pylabrobot/resources/well_tests.py b/pylabrobot/resources/well_tests.py index cfb1c355eb..bcafb76eb7 100644 --- a/pylabrobot/resources/well_tests.py +++ b/pylabrobot/resources/well_tests.py @@ -14,6 +14,7 @@ def test_serialize(self): "size_x": 1, "size_y": 2, "size_z": 3, + "material_z_thickness": None, "bottom_type": "flat", "cross_section_type": "circle", "max_volume": 10, @@ -24,6 +25,12 @@ def test_serialize(self): "type": "Well", "parent_name": None, "location": None, + "rotation": { + "type": "Rotation", + "x": 0, "y": 0, "z": 0 + }, + "compute_volume_from_height": None, + "compute_height_from_volume": None, }) self.assertEqual(Well.deserialize(well.serialize()), well) diff --git a/pylabrobot/scales/chatterbox.py b/pylabrobot/scales/chatterbox.py new file mode 100644 index 0000000000..b451538000 --- /dev/null +++ b/pylabrobot/scales/chatterbox.py @@ -0,0 +1,24 @@ +from pylabrobot.scales.scale_backend import ScaleBackend + + +class ScaleChatterboxBackend(ScaleBackend): + """ Chatter box backend for device-free testing. Prints out all operations. """ + + def __init__(self, dummy_weight: float = 0.0) -> None: + self._dummy_weight = dummy_weight + + async def setup(self) -> None: + print("Setting up the scale.") + + async def stop(self) -> None: + print("Stopping the scale.") + + async def tare(self): + print("Taring the scale") + + async def get_weight(self) -> float: + print("Getting the weight") + return self._dummy_weight + + async def zero(self): + print("Zeroing the scale") diff --git a/pylabrobot/scales/mettler_toledo.py b/pylabrobot/scales/mettler_toledo.py index 606e54d60b..eeb3620304 100644 --- a/pylabrobot/scales/mettler_toledo.py +++ b/pylabrobot/scales/mettler_toledo.py @@ -132,18 +132,19 @@ def __init__(self, port: str) -> None: self.ser: Optional[serial.Serial] = None async def setup(self) -> None: - await super().setup() self.ser = serial.Serial(self.port, baudrate=9600, timeout=1) # set output unit to grams await self.send_command("M21 0 0") async def stop(self) -> None: - await super().stop() if self.ser is not None: self.ser.close() self.ser = None + def serialize(self) -> dict: + return {**super().serialize(), "port": self.port} + async def send_command(self, command: str, timeout: int = 60) -> MettlerToledoResponse: """ Send a command to the scale and receive the response. diff --git a/pylabrobot/scales/scale.py b/pylabrobot/scales/scale.py index c39d6e2b1c..83c49e8a63 100644 --- a/pylabrobot/scales/scale.py +++ b/pylabrobot/scales/scale.py @@ -1,6 +1,6 @@ from typing import Optional -from pylabrobot.machine import Machine +from pylabrobot.machines.machine import Machine from pylabrobot.scales.scale_backend import ScaleBackend diff --git a/pylabrobot/scales/scale_backend.py b/pylabrobot/scales/scale_backend.py index bb6ea23bca..4af3b940af 100644 --- a/pylabrobot/scales/scale_backend.py +++ b/pylabrobot/scales/scale_backend.py @@ -1,5 +1,5 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machine import MachineBackend +from pylabrobot.machines.backends import MachineBackend class ScaleBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/serializer.py b/pylabrobot/serializer.py index aae3935710..5c977813ed 100644 --- a/pylabrobot/serializer.py +++ b/pylabrobot/serializer.py @@ -2,7 +2,9 @@ import enum import inspect +import marshal import sys +import types from typing import Any, Dict, List, Union, cast if sys.version_info >= (3, 10): @@ -35,12 +37,23 @@ def serialize(obj: Any) -> JSON: return {k: serialize(v) for k, v in obj.items()} if isinstance(obj, enum.Enum): return obj.name + if inspect.isfunction(obj): + return { + "type": "function", + "code": marshal.dumps(obj.__code__).hex(), + "closure": serialize(obj.__closure__) if obj.__closure__ else None + } + if isinstance(obj, types.CellType): + return { + "type": "cell", + "contents": serialize(obj.cell_contents) + } if isinstance(obj, object): if hasattr(obj, "serialize"): # if the object has a custom serialize method return cast(JSON, obj.serialize()) else: data: Dict[str, Any] = {} - for key, value in obj.__dict__.items(): + for key, value in vars(obj).items(): if key.startswith("_"): continue data[key] = serialize(value) @@ -49,19 +62,29 @@ def serialize(obj: Any) -> JSON: raise TypeError(f"Cannot serialize {obj} of type {type(obj)}") -def deserialize(data: JSON) -> Any: +def deserialize(data: JSON, allow_marshal: bool = False) -> Any: """ Deserialize an object. """ if isinstance(data, (int, float, str, bool, type(None))): return data if isinstance(data, list): - return [deserialize(item) for item in data] + return [deserialize(item, allow_marshal=allow_marshal) for item in data] if isinstance(data, dict): if "type" in data: # deserialize a class data = data.copy() klass_type = cast(str, data.pop("type")) + if klass_type == "function" and allow_marshal: + assert isinstance(data["code"], str) + code = marshal.loads(bytes.fromhex(data["code"])) + closure = tuple(deserialize(data["closure"], allow_marshal=allow_marshal)) \ + if data["closure"] else None + return types.FunctionType(code, globals(), closure=closure) + if klass_type == "cell": + return types.CellType(deserialize(data["contents"], allow_marshal=allow_marshal)) klass = get_plr_class_from_string(klass_type) - params = {k: deserialize(v) for k, v in data.items()} + params = {k: deserialize(v, allow_marshal=allow_marshal) for k, v in data.items()} return klass(**params) - return {k: deserialize(v) for k, v in data.items()} + return {k: deserialize(v, allow_marshal=allow_marshal) for k, v in data.items()} + if isinstance(data, object): + return data raise TypeError(f"Cannot deserialize {data} of type {type(data)}") diff --git a/pylabrobot/server/liquid_handling_api_tests.py b/pylabrobot/server/liquid_handling_api_tests.py index 3b9554f18a..a37a676928 100644 --- a/pylabrobot/server/liquid_handling_api_tests.py +++ b/pylabrobot/server/liquid_handling_api_tests.py @@ -1,14 +1,17 @@ +import logging +from pathlib import Path import time from typing import cast import unittest +from pylabrobot import Config from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling.backends import SerializingSavingBackend from pylabrobot.resources import ( Plate, TipRack, HTF_L, - Cos_96_EZWash, + Cor_96_wellplate_360ul_Fb, TIP_CAR_480_A00, PLT_CAR_L5AC_A00, no_tip_tracking, @@ -24,7 +27,7 @@ def build_layout() -> HamiltonDeck: tip_car[0] = HTF_L(name="tip_rack_01") plt_car = PLT_CAR_L5AC_A00(name="plate_carrier") - plt_car[0] = plate = Cos_96_EZWash(name="aspiration plate") + plt_car[0] = plate = Cor_96_wellplate_360ul_Fb(name="aspiration plate") plate.get_item("A1").tracker.set_liquids([(None, 400)]) deck = STARLetDeck() @@ -33,6 +36,17 @@ def build_layout() -> HamiltonDeck: return deck +def _wait_for_task_done(base_url, client, task_id): + while True: + response = client.get(base_url + f"/tasks/{task_id}") + if response.json is None: + raise RuntimeError("No JSON in response: " + response.text) + if response.json.get("status") == "running": + time.sleep(0.1) + else: + return response + + class LiquidHandlingApiGeneralTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.backend = SerializingSavingBackend(num_channels=8) @@ -49,19 +63,23 @@ def test_get_index(self): def test_setup(self): # TODO: Figure out how we can configure LH with self.app.test_client() as client: - response = client.post(self.base_url + "/setup") + task = client.post(self.base_url + "/setup") + response = _wait_for_task_done(self.base_url, client, task.json.get("id")) self.assertEqual(response.status_code, 200) - - self.assertIn(response.json.get("status"), {"running", "succeeded"}) + self.assertEqual(response.json.get("status"), "succeeded") time.sleep(0.1) assert self.lh.setup_finished def test_stop(self): with self.app.test_client() as client: - response = client.post(self.base_url + "/stop") + task = client.post(self.base_url + "/setup") + response = _wait_for_task_done(self.base_url, client, task.json.get("id")) + + task = client.post(self.base_url + "/stop") + response = _wait_for_task_done(self.base_url, client, task.json.get("id")) self.assertEqual(response.status_code, 200) - self.assertIn(response.json.get("status"), {"running", "succeeded"}) + self.assertEqual(response.json.get("status"), "succeeded") assert not self.lh.setup_finished @@ -74,7 +92,12 @@ async def test_status(self): await self.lh.setup() response = client.get(self.base_url + "/status") self.assertEqual(response.status_code, 200) - self.assertIn(response.json.get("status"), {"running", "succeeded"}) + self.assertEqual(response.json.get("status"), "running") + + await self.lh.stop() + response = client.get(self.base_url + "/status") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json.get("status"), "stopped") def test_load_labware(self): with self.app.test_client() as client: @@ -121,14 +144,15 @@ def test_tip_pickup(self): tip_spot = tip_rack.get_item("A1") with no_tip_tracking(): tip = tip_spot.get_tip() - response = client.post( + task = client.post( self.base_url + "/pick-up-tips", json={"channels": [{ "resource_name": tip_spot.name, "tip": serialize(tip), "offset": None, }], "use_channels": [0]}) - self.assertIn(response.json.get("status"), {"running", "succeeded"}) + response = _wait_for_task_done(self.base_url, client, task.json.get("id")) + self.assertEqual(response.json.get("status"), "succeeded") self.assertEqual(response.status_code, 200) def test_drop_tip(self): @@ -140,14 +164,15 @@ def test_drop_tip(self): self.test_tip_pickup() # Pick up a tip first - response = client.post( + task = client.post( self.base_url + "/drop-tips", json={"channels": [{ "resource_name": tip_spot.name, "tip": serialize(tip), "offset": None, }], "use_channels": [0]}) - self.assertIn(response.json.get("status"), {"running", "succeeded"}) + response = _wait_for_task_done(self.base_url, client, task.json.get("id")) + self.assertEqual(response.json.get("status"), "succeeded") self.assertEqual(response.status_code, 200) def test_aspirate(self): @@ -156,19 +181,20 @@ def test_aspirate(self): self.test_tip_pickup() # pick up a tip first with self.app.test_client() as client: well = cast(Plate, self.lh.deck.get_resource("aspiration plate")).get_item("A1") - response = client.post( + task = client.post( self.base_url + "/aspirate", json={"channels": [{ "resource_name": well.name, "volume": 10, "tip": serialize(tip), - "offset": None, + "offset": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, "liquids": [[None, 10]], "flow_rate": None, "liquid_height": None, "blow_out_air_volume": 0, }], "use_channels": [0]}) - self.assertIn(response.json.get("status"), {"running", "succeeded"}) + response = _wait_for_task_done(self.base_url, client, task.json.get("id")) + self.assertEqual(response.json.get("status"), "succeeded") self.assertEqual(response.status_code, 200) def test_dispense(self): @@ -177,17 +203,37 @@ def test_dispense(self): self.test_aspirate() # aspirate first with self.app.test_client() as client: well = cast(Plate, self.lh.deck.get_resource("aspiration plate")).get_item("A1") - response = client.post( + task = client.post( self.base_url + "/dispense", json={"channels": [{ "resource_name": well.name, "volume": 10, "tip": serialize(tip), - "offset": None, + "offset": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, "liquids": [[None, 10]], "flow_rate": None, "liquid_height": None, "blow_out_air_volume": 0, }], "use_channels": [0]}) - self.assertIn(response.json.get("status"), {"running", "succeeded"}) + response = _wait_for_task_done(self.base_url, client, task.json.get("id")) + self.assertEqual(response.json.get("status"), "succeeded") + self.assertEqual(response.status_code, 200) + + def test_config(self): + cfg = Config( + logging=Config.Logging( + log_dir=Path("logs"), + level=logging.CRITICAL + ) + ) + with self.app.test_client() as client: + logger = logging.getLogger("pylabrobot") + cur_level = logger.level + response = client.post( + self.base_url + "/config", + json=cfg.as_dict) + new_level = logging.getLogger("pylabrobot").level + self.assertEqual(response.json, cfg.as_dict) self.assertEqual(response.status_code, 200) + self.assertEqual(new_level, logging.CRITICAL) + self.assertNotEqual(cur_level, new_level) diff --git a/pylabrobot/server/liquid_handling_server.py b/pylabrobot/server/liquid_handling_server.py index 94aa3532ba..7aa8e8e19a 100644 --- a/pylabrobot/server/liquid_handling_server.py +++ b/pylabrobot/server/liquid_handling_server.py @@ -5,18 +5,21 @@ import json import os import threading -from typing import Any, Coroutine, List, Tuple, Type, Optional, cast +from typing import Any, Coroutine, List, Tuple, Optional, cast -from flask import Blueprint, Flask, request, jsonify, current_app +from flask import Blueprint, Flask, request, jsonify, current_app, Request import werkzeug +from pylabrobot import configure, Config +from pylabrobot.config.io import ConfigReader +from pylabrobot.config.formats.json_config import JsonLoader from pylabrobot.liquid_handling import LiquidHandler from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.standard import Pickup, Aspiration, Dispense, Drop +from pylabrobot.liquid_handling.standard import Pickup, Aspiration, Dispense, \ + Drop from pylabrobot.resources import Coordinate, Deck, Tip, Liquid from pylabrobot.serializer import deserialize - lh_api = Blueprint("liquid handling", __name__) @@ -31,13 +34,14 @@ def __init__(self, co: Coroutine[Any, Any, None]): def run_in_thread(self) -> None: """ Run the coroutine in a new thread. """ + def runner(): self.status = "running" loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(self.co) - except Exception as e: # pylint: disable=broad-except + except Exception as e: # pylint: disable=broad-except self.error = str(e) self.status = "error" else: @@ -52,8 +56,10 @@ def serialize(self, id_: int) -> dict: d["error"] = self.error return d + tasks: List[Task] = [] + def add_and_run_task(task: Task): id_ = len(tasks) tasks.append(task) @@ -70,6 +76,7 @@ def index(): def get_tasks(): return jsonify([{"id": i, "status": t.status} for i, t in enumerate(tasks)]) + @lh_api.route("/tasks/", methods=["GET"]) def get_task(id_: int): if id_ >= len(tasks): @@ -81,10 +88,12 @@ def get_task(id_: int): async def setup(): return add_and_run_task(Task(current_app.lh.setup())) + @lh_api.route("/stop", methods=["POST"]) async def stop(): return add_and_run_task(Task(current_app.lh.stop())) + @lh_api.route("/status", methods=["GET"]) def get_status(): status = "running" if current_app.lh.setup_finished else "stopped" @@ -108,6 +117,7 @@ def define_labware(): return jsonify({"status": "ok"}) + class ErrorResponse(Exception): def __init__(self, data: dict, status_code: int): self.data = data @@ -123,8 +133,9 @@ async def pick_up_tips(): try: resource = current_app.lh.deck.get_resource(sc["resource_name"]) except ValueError as exc: - raise ErrorResponse({"error": f"resource with name '{sc['resource_name']}' not found"}, - 404) from exc + raise ErrorResponse( + {"error": f"resource with name '{sc['resource_name']}' not found"}, + 404) from exc if not "tip" in sc: raise ErrorResponse({"error": "missing key in json data: tip"}, 400) tip = cast(Tip, deserialize(sc["tip"])) @@ -142,6 +153,7 @@ async def pick_up_tips(): use_channels=use_channels ))) + @lh_api.route("/drop-tips", methods=["POST"]) async def drop_tips(): try: @@ -151,8 +163,9 @@ async def drop_tips(): try: resource = current_app.lh.deck.get_resource(sc["resource_name"]) except ValueError as exc: - raise ErrorResponse({"error": f"resource with name '{sc['resource_name']}' not found"}, - 404) from exc + raise ErrorResponse( + {"error": f"resource with name '{sc['resource_name']}' not found"}, + 404) from exc if not "tip" in sc: raise ErrorResponse({"error": "missing key in json data: tip"}, 400) tip = cast(Tip, deserialize(sc["tip"])) @@ -170,6 +183,7 @@ async def drop_tips(): use_channels=use_channels ))) + @lh_api.route("/aspirate", methods=["POST"]) async def aspirate(): try: @@ -179,8 +193,9 @@ async def aspirate(): try: resource = current_app.lh.deck.get_resource(sc["resource_name"]) except ValueError as exc: - raise ErrorResponse({"error": f"resource with name '{sc['resource_name']}' not found"}, - 404) from exc + raise ErrorResponse( + {"error": f"resource with name '{sc['resource_name']}' not found"}, + 404) from exc if not "tip" in sc: raise ErrorResponse({"error": "missing key in json data: tip"}, 400) tip = cast(Tip, deserialize(sc["tip"])) @@ -191,10 +206,13 @@ async def aspirate(): flow_rate = sc["flow_rate"] liquid_height = sc["liquid_height"] blow_out_air_volume = sc["blow_out_air_volume"] - liquids = cast(List[Tuple[Optional[Liquid], float]], deserialize(sc["liquids"])) - aspirations.append(Aspiration(resource=resource, tip=tip, offset=offset, volume=volume, - flow_rate=flow_rate, liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, - liquids=liquids)) + liquids = cast(List[Tuple[Optional[Liquid], float]], + deserialize(sc["liquids"])) + aspirations.append( + Aspiration(resource=resource, tip=tip, offset=offset, volume=volume, + flow_rate=flow_rate, liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + liquids=liquids)) use_channels = data["use_channels"] except ErrorResponse as e: return jsonify(e.data), e.status_code @@ -207,6 +225,7 @@ async def aspirate(): use_channels=use_channels ))) + @lh_api.route("/dispense", methods=["POST"]) async def dispense(): try: @@ -216,8 +235,9 @@ async def dispense(): try: resource = current_app.lh.deck.get_resource(sc["resource_name"]) except ValueError as exc: - raise ErrorResponse({"error": f"resource with name '{sc['resource_name']}' not found"}, - 404) from exc + raise ErrorResponse( + {"error": f"resource with name '{sc['resource_name']}' not found"}, + 404) from exc if not "tip" in sc: raise ErrorResponse({"error": "missing key in json data: tip"}, 400) tip = cast(Tip, deserialize(sc["tip"])) @@ -228,10 +248,13 @@ async def dispense(): flow_rate = sc["flow_rate"] liquid_height = sc["liquid_height"] blow_out_air_volume = sc["blow_out_air_volume"] - liquids = cast(List[Tuple[Optional[Liquid], float]], deserialize(sc["liquids"])) - dispenses.append(Dispense(resource=resource, tip=tip, offset=offset, volume=volume, - flow_rate=flow_rate, liquid_height=liquid_height, blow_out_air_volume=blow_out_air_volume, - liquids=liquids)) + liquids = cast(List[Tuple[Optional[Liquid], float]], + deserialize(sc["liquids"])) + dispenses.append( + Dispense(resource=resource, tip=tip, offset=offset, volume=volume, + flow_rate=flow_rate, liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + liquids=liquids)) use_channels = data["use_channels"] except ErrorResponse as e: return jsonify(e.data), e.status_code @@ -245,6 +268,21 @@ async def dispense(): ))) +class HttpReader(ConfigReader): + def read(self, r: Request) -> Config: + return self.format_loader.load(r.stream) + + +CONFIG_READER = HttpReader(format_loader=JsonLoader()) + + +@lh_api.route("/config", methods=["POST"]) +async def config(): + cfg = CONFIG_READER.read(request) + configure(cfg) + return jsonify(cfg.as_dict) + + def create_app(lh: LiquidHandler): """ Create a Flask app with the given LiquidHandler """ app = Flask(__name__) diff --git a/pylabrobot/shaking/backend.py b/pylabrobot/shaking/backend.py index c19056bf63..25824f6378 100644 --- a/pylabrobot/shaking/backend.py +++ b/pylabrobot/shaking/backend.py @@ -1,5 +1,5 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machine import MachineBackend +from pylabrobot.machines.backends import MachineBackend class ShakerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/shaking/chatterbox.py b/pylabrobot/shaking/chatterbox.py new file mode 100644 index 0000000000..67e77b2a40 --- /dev/null +++ b/pylabrobot/shaking/chatterbox.py @@ -0,0 +1,11 @@ +from pylabrobot.shaking import ShakerBackend + + +class ShakerChatterboxBackend(ShakerBackend): + """ Backend for a shaker machine """ + + async def shake(self, speed: float): + print("Shaking at speed", speed) + + async def stop_shaking(self): + print("Stopping shaking") diff --git a/pylabrobot/shaking/shaker.py b/pylabrobot/shaking/shaker.py index 74f0150580..ae38d30115 100644 --- a/pylabrobot/shaking/shaker.py +++ b/pylabrobot/shaking/shaker.py @@ -1,12 +1,13 @@ import asyncio from typing import Optional -from pylabrobot.machine import Machine +from pylabrobot.machines.machine import Machine +from pylabrobot.resources.resource_holder import ResourceHolderMixin from .backend import ShakerBackend -class Shaker(Machine): +class Shaker(ResourceHolderMixin, Machine): """ A shaker machine """ def __init__( diff --git a/pylabrobot/temperature_controlling/backend.py b/pylabrobot/temperature_controlling/backend.py index 40eb37cdaf..29fdf403e5 100644 --- a/pylabrobot/temperature_controlling/backend.py +++ b/pylabrobot/temperature_controlling/backend.py @@ -1,5 +1,5 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machine import MachineBackend +from pylabrobot.machines.backends import MachineBackend class TemperatureControllerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/temperature_controlling/chatterbox.py b/pylabrobot/temperature_controlling/chatterbox.py new file mode 100644 index 0000000000..0cd3eb6614 --- /dev/null +++ b/pylabrobot/temperature_controlling/chatterbox.py @@ -0,0 +1,25 @@ +from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend + + +class TemperatureControllerChatterboxBackend(TemperatureControllerBackend): + """ Chatter box backend for device-free testing. Prints out all operations. """ + + def __init__(self, dummy_temperature: float = 0.0) -> None: + self._dummy_temperature = dummy_temperature + + async def setup(self): + print("Setting up the temperature controller.") + + async def stop(self): + print("Stopping the temperature controller.") + + async def set_temperature(self, temperature: float): + print(f"Setting the temperature to {temperature}.") + self._dummy_temperature = temperature + + async def get_current_temperature(self) -> float: + print("Getting the current temperature.") + return self._dummy_temperature + + async def deactivate(self): + print("Deactivating the temperature controller.") diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/temperature_controlling/opentrons.py index b2812b1c3b..e81e40dfbb 100644 --- a/pylabrobot/temperature_controlling/opentrons.py +++ b/pylabrobot/temperature_controlling/opentrons.py @@ -1,76 +1,40 @@ -from pylabrobot.resources import Coordinate, ItemizedResource, Tube -from pylabrobot.resources.itemized_resource import create_equally_spaced +from typing import Optional + +from pylabrobot.resources import Coordinate, ItemizedResource +from pylabrobot.resources.opentrons.module import OTModule from pylabrobot.temperature_controlling.temperature_controller import TemperatureController from pylabrobot.temperature_controlling.opentrons_backend import OpentronsTemperatureModuleBackend - -class OpentronsTemperatureModuleV2(TemperatureController): +class OpentronsTemperatureModuleV2(TemperatureController, OTModule): """ Opentrons temperature module v2. https://opentrons.com/products/modules/temperature/ + https://shop.opentrons.com/aluminum-block-set/ """ - def __init__(self, name: str, opentrons_id: str): + def __init__(self, name: str, opentrons_id: str, child: Optional[ItemizedResource] = None): """ Create a new Opentrons temperature module v2. Args: name: Name of the temperature module. opentrons_id: Opentrons ID of the temperature module. Get it from - `OpentronsTemperatureModuleBackend.list_connected_modules()`. + `OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. + child: Optional child resource like a tube rack or well plate to use on the + temperature controller module. """ - TemperatureController.__init__( - self=self, + super().__init__( name=name, - size_x=112.0, - size_y=73.6, - - # size_x=127.0, - # size_y=86.0, - - size_z=140.0, + size_x=193.5, + size_y=89.2, + size_z=84.0, # height without any aluminum block backend=OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id), category="temperature_controller", - model="opentrons_temperature_module_v2" + model="temperatureModuleV2" # Must match OT moduleModel in list_connected_modules() ) - b = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id) - - # verified guesses - # TODO: make this a proper TubeRack - self.tube_rack = ItemizedResource( - name=f"{name}_tube_rack", - size_x=112.0, - size_y=73.6, - size_z=140.0, - - items=create_equally_spaced( - Tube, - num_items_x=6, - num_items_y=4, - dx=22, - dy=18, - # dx=22 - (86 - 73.6), - # dy=18 - (86 - 73.6), - dz=45.0, - - item_dx=17.2, - item_dy=17.2, - - size_x=10.0, - size_y=10.0, - size_z=40.0, - ), - - category="temperature_module", - model="opentrons_temperature_module_v2", - ) - self.assign_child_resource(self.tube_rack, location=Coordinate( - x=0, - y=0, - # x=(127 - 112.0)/2, - # y=(86 - 73.6)/2, - z=0 - )) + self.backend = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id) + self.child = child - self.backend: OpentronsTemperatureModuleBackend = b # fix type + if child is not None: + self.assign_child_resource(child, location=Coordinate(x=0, y=0, z=0)) diff --git a/pylabrobot/temperature_controlling/opentrons_backend.py b/pylabrobot/temperature_controlling/opentrons_backend.py index 19bd977ffd..706b08acbf 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/temperature_controlling/opentrons_backend.py @@ -23,7 +23,7 @@ def __init__(self, opentrons_id: str): Args: opentrons_id: Opentrons ID of the temperature module. Get it from - `OpentronsTemperatureModuleBackend.list_connected_modules()`. + `OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. """ self.opentrons_id = opentrons_id @@ -32,20 +32,25 @@ def __init__(self, opentrons_id: str): " Only supported on Python 3.10.") async def setup(self): - await super().setup() + pass async def stop(self): await self.deactivate() - await super().stop() + + def serialize(self) -> dict: + return {**super().serialize(), "opentrons_id": self.opentrons_id} async def set_temperature(self, temperature: float): + # pylint: disable=possibly-used-before-assignment ot_api.modules.temperature_module_set_temperature(celsius=temperature, module_id=self.opentrons_id) async def deactivate(self): + # pylint: disable=possibly-used-before-assignment ot_api.modules.temperature_module_deactivate(module_id=self.opentrons_id) async def get_current_temperature(self) -> float: + # pylint: disable=possibly-used-before-assignment modules = ot_api.modules.list_connected_modules() for module in modules: if module["id"] == self.opentrons_id: diff --git a/pylabrobot/temperature_controlling/temperature_controller.py b/pylabrobot/temperature_controlling/temperature_controller.py index 5fa4230e9d..32eb66a50f 100644 --- a/pylabrobot/temperature_controlling/temperature_controller.py +++ b/pylabrobot/temperature_controlling/temperature_controller.py @@ -2,12 +2,12 @@ import time from typing import Optional -from pylabrobot.machine import Machine - +from pylabrobot.machines.machine import Machine +from pylabrobot.resources.resource_holder import ResourceHolderMixin from .backend import TemperatureControllerBackend -class TemperatureController(Machine): +class TemperatureController(ResourceHolderMixin, Machine): """ Temperature controller, for heating or for cooling. """ def __init__( @@ -20,18 +20,11 @@ def __init__( category: str = "temperature_controller", model: Optional[str] = None ): - super().__init__(name, size_x, size_y, size_z, backend, category, model) + super().__init__(name=name, size_x=size_x, size_y=size_y, size_z=size_z, + backend=backend, category=category, model=model) self.backend: TemperatureControllerBackend = backend # fix type self.target_temperature: Optional[float] = None - async def setup(self): - """ Setup the temperature controller. """ - return await self.backend.setup() - - async def stop(self): - """ Stop the temperature controller. """ - return await self.backend.stop() - async def set_temperature(self, temperature: float): """ Set the temperature of the temperature controller. diff --git a/pylabrobot/testing/test_data/test_deck.lay b/pylabrobot/testing/test_data/test_deck.lay deleted file mode 100644 index d04b1c2bb8..0000000000 Binary files a/pylabrobot/testing/test_data/test_deck.lay and /dev/null differ diff --git a/pylabrobot/tilting/chatterbox.py b/pylabrobot/tilting/chatterbox.py new file mode 100644 index 0000000000..ce7d654d36 --- /dev/null +++ b/pylabrobot/tilting/chatterbox.py @@ -0,0 +1,6 @@ +from pylabrobot.tilting import TilterBackend + + +class TilterChatterboxBackend(TilterBackend): + async def set_angle(self, angle: float): + print(f"Setting the angle to {angle}.") diff --git a/pylabrobot/tilting/tilter.py b/pylabrobot/tilting/tilter.py index 9c72d582e4..24829c9c60 100644 --- a/pylabrobot/tilting/tilter.py +++ b/pylabrobot/tilting/tilter.py @@ -4,11 +4,11 @@ from pylabrobot.machines import Machine from pylabrobot.resources import Coordinate, Plate from pylabrobot.resources.well import CrossSectionType, Well - +from pylabrobot.resources.resource_holder import ResourceHolderMixin from .tilter_backend import TilterBackend -class Tilter(Machine): +class Tilter(ResourceHolderMixin, Machine): """ Resources that tilt plates. """ def __init__( @@ -137,7 +137,7 @@ def experimental_get_well_drain_offsets( assert well.cross_section_type == CrossSectionType.CIRCLE, \ "Wells must have circular cross-section" - diameter = well.get_size_x() # assuming circular well + diameter = well.get_absolute_size_x() # assuming circular well radius = diameter / 2 if n_tips > 1: diff --git a/pylabrobot/utils/__init__.py b/pylabrobot/utils/__init__.py index 58f5a3f5cd..4a732986d0 100644 --- a/pylabrobot/utils/__init__.py +++ b/pylabrobot/utils/__init__.py @@ -1,2 +1,3 @@ -from .list import assert_shape, reshape_2d, expand -from .positions import string_to_position, string_to_index, string_to_indices, string_to_pattern +from .list import assert_shape, reshape_2d +from .positions import expand_string_range +from .object_parsing import find_subclass diff --git a/pylabrobot/utils/list.py b/pylabrobot/utils/list.py index 1870c5f606..341ff102bc 100644 --- a/pylabrobot/utils/list.py +++ b/pylabrobot/utils/list.py @@ -1,7 +1,6 @@ """ Utilities for working with lists. """ -import collections.abc -from typing import List, Tuple, TypeVar, Union, Sequence, cast +from typing import List, Tuple, TypeVar T = TypeVar("T") @@ -44,14 +43,3 @@ def reshape_2d(list_: List[T], shape: Tuple[int, int]) -> List[List[T]]: new_list[i].append(list_[i * shape[1] + j]) return new_list - - -def expand(list_or_item: Union[Sequence[T], T], n: int) -> List[T]: - if n <= 0: - raise ValueError(f"Cannot expand list {list_or_item} by {n}.") - if isinstance(list_or_item, collections.abc.Sequence) and not isinstance(list_or_item, str): - if len(list_or_item) != n: - raise ValueError(f"Expected list of length {n}, got {len(list_or_item)}.") - return list(list_or_item) - # cast to T to avoid mypy error (thinks it's a string). This can probably be written better. - return [cast(T, list_or_item)] * n diff --git a/pylabrobot/utils/list_tests.py b/pylabrobot/utils/list_tests.py index 2fc068b302..843ca7000d 100644 --- a/pylabrobot/utils/list_tests.py +++ b/pylabrobot/utils/list_tests.py @@ -2,11 +2,7 @@ import unittest -from pylabrobot.utils import ( - assert_shape, - reshape_2d, - expand -) +from pylabrobot.utils import assert_shape, reshape_2d class TestListUtils(unittest.TestCase): @@ -29,13 +25,3 @@ def test_reshape_2d(self): reshape_2d([1, 2, 3, 4], (2, 3)) with self.assertRaises(ValueError): reshape_2d([1, 2, 3, 4, 5, 6], (2, 2)) - - def test_expand(self): - self.assertEqual(expand(1, n=3), [1, 1, 1]) - self.assertEqual(expand([1, 2, 3], n=3), [1, 2, 3]) - self.assertEqual(expand("test", n=3), ["test", "test", "test"]) - - with self.assertRaises(ValueError): - expand(1, n=0) - with self.assertRaises(ValueError): - expand(1, n=-1) diff --git a/pylabrobot/utils/positions.py b/pylabrobot/utils/positions.py index af8ee1155d..e734147b16 100644 --- a/pylabrobot/utils/positions.py +++ b/pylabrobot/utils/positions.py @@ -1,118 +1,35 @@ -""" Utilities for working with positions and position strings. - -These follow the Hamilton VENUS style, which is like MS Excel, but transposed. So `B1` is the cell -below the top left (`A1`) and `A2` is the cell to the right of `A1`. -""" - -import itertools +from string import ascii_uppercase as LETTERS import typing def string_to_position(position_string: str) -> typing.Tuple[int, int]: - """Convert a string to a list of patterns. - - Positions are formatted as `` where `` is the row string (`A` for row 1, `B` - for row 2, etc.) and `` is the column number. For example, `A1` is the top left corner - of the resource and `H12` is the bottom right. This method returns the index for such a - position. - - Examples: - >>> _string_to_pattern("A1") - (0, 0) - - >>> _string_to_pattern("A3") - (0, 2) - - >>> _string_to_pattern("C1") - (2, 0) - - Args: - position_string: The string to convert. - - Returns: - A list of patterns. - """ - - row = ord(position_string[0]) - 65 - column = int(position_string[1:]) - 1 - return (row, column) - + raise NotImplementedError("Deprecated.") # TODO(deprecate-ordered-items) def string_to_index(position_string: str, num_rows: int, num_columns: int) -> int: - """ Convert a position string to an index. - - Args: - position_string: The position string. - num_rows: The number of rows of the resource that's indexed. - num_columns: The number of columns of the resource that's indexed. - - Returns: - The index of the position. - """ - - row, column = string_to_position(position_string) - assert row < num_rows, f"Row must be less than {num_rows}" - assert column < num_columns, f"Column must be less than {num_columns}" - return row + column * num_rows - - -def string_to_indices(position_range_string: str, num_rows: int) \ - -> typing.List[int]: - """ Convert a position string to a list of indices. - - Args: - position_string: The position string. - num_rows: The number of rows of the resource that's indexed. - - Returns: - A list of indices. - """ - - start_str, end_str = position_range_string.split(":") - - start, end = string_to_position(start_str), string_to_position(end_str) - x_dir = 1 if start[0] <= end[0] else -1 - y_dir = 1 if start[1] <= end[1] else -1 - indices = [] - for row, column in itertools.product(range(start[0], end[0] + x_dir, x_dir), - range(start[1], end[1] + y_dir, y_dir)): - indices.append(row + column * num_rows) - return indices + raise NotImplementedError("Deprecated.") # TODO(deprecate-ordered-items) +def string_to_indices(position_range_string: str, num_rows: int) -> typing.List[int]: + raise NotImplementedError("Deprecated.") # TODO(deprecate-ordered-items) def string_to_pattern(position_range_string: str, num_rows: int, num_columns: int) \ -> typing.List[typing.List[bool]]: - """ Convert a position string to a pattern. + raise NotImplementedError("Deprecated.") # TODO(deprecate-ordered-items) + +def expand_string_range(range_str: str) -> list: + """ Turns a range string into a list of position strings. Horizontal, vertical, or grids. Args: - position_string: The position string. + range_str: A string showing a range, like "A1:C3". Returns: - A list of lists of booleans. - - Examples: - Convert `"A1:A3"` to a pattern. - - >>> _string_range_to_pattern("A1:C3") - [[True, True, True, False, False, ...], [True, True, True, False, False...], - [True, True, True, False, False...], ...] - - Convert `"A1:A3"` to a pattern. - - >>> _string_range_to_pattern("A1:A3") - [[True, True, True, False, False, ...], [False, False, ...], ...] - - Convert `"A1:C1"` to a pattern. - - >>> _string_range_to_pattern("A1:C1") - [[True, False, ...], [True, False, ...], [True, False, ...], [False, False, ...], ...] - + A list of position identifier strings. """ - - # Split the position string into a list of position strings - start_str, end_str = position_range_string.split(":") - start, end = string_to_position(start_str), string_to_position(end_str) - positions = [[False for _ in range(num_columns)] for _ in range(num_rows)] - for row, column in itertools.product(range(start[0], end[0] + 1), range(start[1], end[1] + 1)): - positions[row][column] = True - return positions + if ":" not in range_str: + raise ValueError(f"Invalid range: {range_str}") + + start, end = range_str.split(":") + start_col, start_row = LETTERS.index(start[0]), int(start[1:]) + end_col, end_row = LETTERS.index(end[0]), int(end[1:]) + row_range = range(start_row, end_row+1) if start_row < end_row else range(start_row, end_row-1,-1) + col_range = range(start_col, end_col+1) if start_col < end_col else range(start_col, end_col-1,-1) + return [f"{LETTERS[col]}{row}" for col in col_range for row in row_range] diff --git a/pylabrobot/utils/positions_tests.py b/pylabrobot/utils/positions_tests.py index 5a10016b93..d6e7ee1333 100644 --- a/pylabrobot/utils/positions_tests.py +++ b/pylabrobot/utils/positions_tests.py @@ -2,12 +2,7 @@ import unittest -from pylabrobot.utils import ( - string_to_position, - string_to_index, - string_to_indices, - string_to_pattern -) +from pylabrobot.utils import expand_string_range class TestPositions(unittest.TestCase): @@ -17,35 +12,13 @@ def setUp(self) -> None: super().setUp() self.maxDiff = None - def test_string_to_pattern(self): - self.assertEqual(string_to_position("A1"), (0, 0)) - self.assertEqual(string_to_position("A3"), (0, 2)) - self.assertEqual(string_to_position("C1"), (2, 0)) + def test_expand_string_range(self): + self.assertEqual(expand_string_range("A1:A3"), ["A1", "A2", "A3"]) + self.assertEqual(expand_string_range("A1:C1"), ["A1", "B1", "C1"]) + self.assertEqual(expand_string_range("A1:C3"), ["A1", "A2", "A3", + "B1", "B2", "B3", + "C1", "C2", "C3"]) - def test_string_to_index(self): - self.assertEqual(string_to_index("A1", num_rows=8, num_columns=12), 0) - self.assertEqual(string_to_index("A3", num_rows=8, num_columns=12), 16) - self.assertEqual(string_to_index("C1", num_rows=8, num_columns=12), 2) - - def test_string_to_indices(self): - self.assertEqual(string_to_indices("A1:A3", num_rows=8), [0, 8, 16]) - self.assertEqual(string_to_indices("A1:C1", num_rows=8), [0, 1, 2]) - self.assertEqual(string_to_indices("A1:C3", num_rows=8), - [0, 8, 16, 1, 9, 17, 2, 10, 18]) - - def test_string_to_indices_reverse(self): - self.assertEqual(string_to_indices("A3:A1", num_rows=8), [16, 8, 0]) - - def test_string_range_to_pattern(self): - self.assertEqual(string_to_pattern("A1:C3", num_rows=8, num_columns=12), - [[True]*3 + [False]*9]*3 + [[False]*12]*5) - self.assertEqual(string_to_pattern("A1:A3", num_rows=8, num_columns=12), - [[True]*3 + [False]*9] + [[False]*12]*7) - self.assertEqual(string_to_pattern("A1:C1", num_rows=8, num_columns=12), - [[True] + [False] * 11]*3 + [[False]*12]*5) - - def test_num_rows(self): - self.assertEqual(string_to_index("A2", num_rows=2, num_columns=12), 2) - - with self.assertRaises(AssertionError): - string_to_index("C1", num_rows=2, num_columns=12) + def test_expand_string_range_reverse(self): + self.assertEqual(expand_string_range("C3:C1"), ["C3", "C2", "C1"]) + self.assertEqual(expand_string_range("C1:A1"), ["C1", "B1", "A1"]) diff --git a/pylabrobot/visualizer/index.html b/pylabrobot/visualizer/index.html index 7373d796c5..cbc748dd64 100644 --- a/pylabrobot/visualizer/index.html +++ b/pylabrobot/visualizer/index.html @@ -15,17 +15,62 @@ integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" /> - - - +