├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ ├── cla.yml │ ├── pipeline.yml │ ├── pre-commit.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CLA.md ├── LICENSE ├── README.md ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── conf.py │ ├── index.rst │ ├── notebooks │ ├── 01_Labware_Basics.ipynb │ ├── 02_Worklist_Basics.ipynb │ ├── 03_Large_Volumes.ipynb │ ├── 04_Composition_Tracking.ipynb │ ├── 05_DilutionPlan.ipynb │ ├── 06_Advanced_Worklist_Commands.ipynb │ └── 07_TipMasks.ipynb │ ├── robotools_evotools.rst │ ├── robotools_fluenttools.rst │ ├── robotools_liquidhandling.rst │ ├── robotools_transform.rst │ ├── robotools_utils.rst │ └── robotools_worklists.rst ├── notebooks └── README.md ├── pyproject.toml ├── requirements-dev.txt └── robotools ├── __init__.py ├── evotools ├── __init__.py ├── commands.py ├── test_commands.py ├── test_utils.py ├── test_worklist.py ├── types.py ├── utils.py └── worklist.py ├── fluenttools ├── __init__.py ├── test_utils.py ├── test_worklist.py ├── utils.py └── worklist.py ├── liquidhandling ├── __init__.py ├── composition.py ├── exceptions.py ├── labware.py ├── test_composition.py └── test_labware.py ├── py.typed ├── test_transform.py ├── test_utils.py ├── test_worklists.py ├── transform.py ├── utils.py └── worklists ├── __init__.py ├── base.py ├── exceptions.py ├── test_base.py ├── test_lvh.py ├── test_utils.py └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # exclude tests files from coverage calculation 4 | **/test*.py 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | # Keep Python dependencies updated 9 | - package-ecosystem: "pip" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | -------------------------------------------------------------------------------- /.github/workflows/cla.yml: -------------------------------------------------------------------------------- 1 | name: "CLA Assistant" 2 | on: 3 | issue_comment: 4 | types: [created] 5 | pull_request_target: 6 | types: [opened,closed,synchronize] 7 | 8 | # explicitly configure permissions, in case your GITHUB_TOKEN workflow permissions are set to read-only in repository settings 9 | permissions: 10 | actions: write 11 | contents: read 12 | pull-requests: write 13 | statuses: write 14 | 15 | jobs: 16 | CLAAssistant: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: "CLA Assistant" 20 | if: (github.event.comment.body == 'recheck' || contains(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA')) || github.event_name == 'pull_request_target' 21 | uses: contributor-assistant/github-action@v2.6.1 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | # The below token has repo scope for the project configured further below to store signatures. 25 | PERSONAL_ACCESS_TOKEN: ${{ secrets.JUBIOTECH_CLA_SIGNATURES }} 26 | PATH_TO_CLA: 'https://github.com/jubiotech/robotools/blob/master/CLA.md' 27 | with: 28 | path-to-signatures: 'signatures/version1/signatures_robotools.json' 29 | path-to-document: $PATH_TO_CLA 30 | # branch should not be protected 31 | branch: 'main' 32 | allowlist: dependabot[bot] 33 | 34 | # the followings are the optional inputs - If the optional inputs are not given, then default values will be taken 35 | # 👇 the remote organization and repo where the signatures should be stored 36 | remote-organization-name: jubiotech 37 | remote-repository-name: cla-signatures 38 | #create-file-commit-message: 'For example: Creating file for storing CLA Signatures' 39 | #signed-commit-message: 'For example: $contributorName has signed the CLA in $owner/$repo#$pullRequestNo' 40 | custom-notsigned-prcomment: | 41 | Thank you for your submission, we really appreciate it! 42 | 43 | Like many open-source projects we ask that you sign our [Contributor License Agreement](${{ env.PATH_TO_CLA }}) before we can accept your contribution. 44 | To sign, please post two separate comments based on the following templates 👇 45 | 46 | 1. Comment: 47 | 48 | ---- 49 | ```markdown 50 | - [ ] The JuBiotech maintainers know my real name. 51 | 52 | At least one of the following two applies: 53 | 54 | - [ ] The JuBiotech maintainers know my current employer. 55 | 56 | - [ ] I am *not* making this contribution on behalf of my current employer. 57 | ``` 58 | 59 | ---- 60 | 61 | 2. Comment: 62 | custom-pr-sign-comment: I have read the CLA Document and I hereby sign the CLA. 63 | #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.' 64 | # 👇 prevent contributors from revoking signatures after the merge (this is also the default) 65 | lock-pullrequest-aftermerge: true 66 | #use-dco-flag: true - If you are using DCO instead of CLA 67 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: pipeline 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | 8 | jobs: 9 | test-job: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | python-version: ["3.10", "3.11", "3.12"] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | run: | 22 | pip install -e . 23 | pip install -r requirements-dev.txt 24 | - name: Lint with flake8 25 | run: | 26 | # stop the build if there are Python syntax errors or undefined names 27 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 28 | # exit-zero treats all errors as warnings 29 | flake8 . --count --exit-zero --statistics 30 | - name: Test with pytest 31 | run: | 32 | pytest --cov=./robotools --cov-report xml --cov-report term-missing robotools 33 | - name: Upload coverage 34 | uses: codecov/codecov-action@v5.4.3 35 | with: 36 | file: ./coverage.xml 37 | - name: Test Wheel install and import 38 | run: | 39 | python -m build 40 | cd dist 41 | pip install robotools*.whl 42 | python -c "import robotools; print(robotools.__version__)" 43 | - name: Test Wheel with twine 44 | run: | 45 | twine check dist/* 46 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-python@v5 14 | - uses: pre-commit/action@v3.0.1 15 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release-pipeline 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | 9 | jobs: 10 | release-job: 11 | runs-on: ubuntu-latest 12 | env: 13 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.11" 20 | - name: Install dependencies 21 | run: | 22 | pip install -e . 23 | pip install -r requirements-dev.txt 24 | - name: Test with pytest 25 | run: | 26 | pytest --cov=./robotools --cov-report xml --cov-report term-missing robotools 27 | - name: Build package 28 | run: | 29 | python -m build 30 | - name: Check version number match 31 | run: | 32 | echo "GITHUB_REF: ${GITHUB_REF}" 33 | # Make sure the package version is the same as the tag 34 | grep -Rq "^Version: ${GITHUB_REF:11}$" robotools.egg-info/PKG-INFO 35 | - name: Publish to PyPI 36 | run: | 37 | twine check dist/* 38 | twine upload --repository pypi --username __token__ --password ${PYPI_TOKEN} dist/* 39 | - name: Test installation 40 | run: | 41 | sleep 120 42 | pip install robotools==${GITHUB_REF:11} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python,visualstudio,visualstudiocode 2 | # Edit at https://www.gitignore.io/?templates=python,visualstudio,visualstudiocode 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # celery beat schedule file 89 | celerybeat-schedule 90 | 91 | # SageMath parsed files 92 | *.sage.py 93 | 94 | # Environments 95 | .env 96 | .venv 97 | env/ 98 | venv/ 99 | ENV/ 100 | env.bak/ 101 | venv.bak/ 102 | 103 | # Spyder project settings 104 | .spyderproject 105 | .spyproject 106 | 107 | # Rope project settings 108 | .ropeproject 109 | 110 | # mkdocs documentation 111 | /site 112 | 113 | # mypy 114 | .mypy_cache/ 115 | .dmypy.json 116 | dmypy.json 117 | 118 | # Pyre type checker 119 | .pyre/ 120 | 121 | ### Python Patch ### 122 | .venv/ 123 | 124 | ### VisualStudioCode ### 125 | .vscode/* 126 | !.vscode/settings.json 127 | !.vscode/tasks.json 128 | !.vscode/launch.json 129 | !.vscode/extensions.json 130 | 131 | ### VisualStudioCode Patch ### 132 | # Ignore all local history of files 133 | .history 134 | 135 | ### VisualStudio ### 136 | ## Ignore Visual Studio temporary files, build results, and 137 | ## files generated by popular Visual Studio add-ons. 138 | ## 139 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 140 | 141 | # User-specific files 142 | *.rsuser 143 | *.suo 144 | *.user 145 | *.userosscache 146 | *.sln.docstates 147 | 148 | # User-specific files (MonoDevelop/Xamarin Studio) 149 | *.userprefs 150 | 151 | # Build results 152 | [Dd]ebug/ 153 | [Dd]ebugPublic/ 154 | [Rr]elease/ 155 | [Rr]eleases/ 156 | x64/ 157 | x86/ 158 | [Aa][Rr][Mm]/ 159 | [Aa][Rr][Mm]64/ 160 | bld/ 161 | [Bb]in/ 162 | [Oo]bj/ 163 | [Ll]og/ 164 | 165 | # Visual Studio 2015/2017 cache/options directory 166 | .vs/ 167 | # Uncomment if you have tasks that create the project's static files in wwwroot 168 | #wwwroot/ 169 | 170 | # Visual Studio 2017 auto generated files 171 | Generated\ Files/ 172 | 173 | # MSTest test Results 174 | [Tt]est[Rr]esult*/ 175 | [Bb]uild[Ll]og.* 176 | 177 | # NUNIT 178 | *.VisualState.xml 179 | TestResult.xml 180 | 181 | # Build Results of an ATL Project 182 | [Dd]ebugPS/ 183 | [Rr]eleasePS/ 184 | dlldata.c 185 | 186 | # Benchmark Results 187 | BenchmarkDotNet.Artifacts/ 188 | 189 | # .NET Core 190 | project.lock.json 191 | project.fragment.lock.json 192 | artifacts/ 193 | 194 | # StyleCop 195 | StyleCopReport.xml 196 | 197 | # Files built by Visual Studio 198 | *_i.c 199 | *_p.c 200 | *_h.h 201 | *.ilk 202 | *.meta 203 | *.obj 204 | *.iobj 205 | *.pch 206 | *.pdb 207 | *.ipdb 208 | *.pgc 209 | *.pgd 210 | *.rsp 211 | *.sbr 212 | *.tlb 213 | *.tli 214 | *.tlh 215 | *.tmp 216 | *.tmp_proj 217 | *_wpftmp.csproj 218 | *.vspscc 219 | *.vssscc 220 | .builds 221 | *.pidb 222 | *.svclog 223 | *.scc 224 | 225 | # Chutzpah Test files 226 | _Chutzpah* 227 | 228 | # Visual C++ cache files 229 | ipch/ 230 | *.aps 231 | *.ncb 232 | *.opendb 233 | *.opensdf 234 | *.sdf 235 | *.cachefile 236 | *.VC.db 237 | *.VC.VC.opendb 238 | 239 | # Visual Studio profiler 240 | *.psess 241 | *.vsp 242 | *.vspx 243 | *.sap 244 | 245 | # Visual Studio Trace Files 246 | *.e2e 247 | 248 | # TFS 2012 Local Workspace 249 | $tf/ 250 | 251 | # Guidance Automation Toolkit 252 | *.gpState 253 | 254 | # ReSharper is a .NET coding add-in 255 | _ReSharper*/ 256 | *.[Rr]e[Ss]harper 257 | *.DotSettings.user 258 | 259 | # JustCode is a .NET coding add-in 260 | .JustCode 261 | 262 | # TeamCity is a build add-in 263 | _TeamCity* 264 | 265 | # DotCover is a Code Coverage Tool 266 | *.dotCover 267 | 268 | # AxoCover is a Code Coverage Tool 269 | .axoCover/* 270 | !.axoCover/settings.json 271 | 272 | # Visual Studio code coverage results 273 | *.coverage 274 | *.coveragexml 275 | 276 | # NCrunch 277 | _NCrunch_* 278 | .*crunch*.local.xml 279 | nCrunchTemp_* 280 | 281 | # MightyMoose 282 | *.mm.* 283 | AutoTest.Net/ 284 | 285 | # Web workbench (sass) 286 | .sass-cache/ 287 | 288 | # Installshield output folder 289 | [Ee]xpress/ 290 | 291 | # DocProject is a documentation generator add-in 292 | DocProject/buildhelp/ 293 | DocProject/Help/*.HxT 294 | DocProject/Help/*.HxC 295 | DocProject/Help/*.hhc 296 | DocProject/Help/*.hhk 297 | DocProject/Help/*.hhp 298 | DocProject/Help/Html2 299 | DocProject/Help/html 300 | 301 | # Click-Once directory 302 | publish/ 303 | 304 | # Publish Web Output 305 | *.[Pp]ublish.xml 306 | *.azurePubxml 307 | # Note: Comment the next line if you want to checkin your web deploy settings, 308 | # but database connection strings (with potential passwords) will be unencrypted 309 | *.pubxml 310 | *.publishproj 311 | 312 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 313 | # checkin your Azure Web App publish settings, but sensitive information contained 314 | # in these scripts will be unencrypted 315 | PublishScripts/ 316 | 317 | # NuGet Packages 318 | *.nupkg 319 | # The packages folder can be ignored because of Package Restore 320 | **/[Pp]ackages/* 321 | # except build/, which is used as an MSBuild target. 322 | !**/[Pp]ackages/build/ 323 | # Uncomment if necessary however generally it will be regenerated when needed 324 | #!**/[Pp]ackages/repositories.config 325 | # NuGet v3's project.json files produces more ignorable files 326 | *.nuget.props 327 | *.nuget.targets 328 | 329 | # Microsoft Azure Build Output 330 | csx/ 331 | *.build.csdef 332 | 333 | # Microsoft Azure Emulator 334 | ecf/ 335 | rcf/ 336 | 337 | # Windows Store app package directories and files 338 | AppPackages/ 339 | BundleArtifacts/ 340 | Package.StoreAssociation.xml 341 | _pkginfo.txt 342 | *.appx 343 | 344 | # Visual Studio cache files 345 | # files ending in .cache can be ignored 346 | *.[Cc]ache 347 | # but keep track of directories ending in .cache 348 | !?*.[Cc]ache/ 349 | 350 | # Others 351 | ClientBin/ 352 | ~$* 353 | *~ 354 | *.dbmdl 355 | *.dbproj.schemaview 356 | *.jfm 357 | *.pfx 358 | *.publishsettings 359 | orleans.codegen.cs 360 | 361 | # Including strong name files can present a security risk 362 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 363 | #*.snk 364 | 365 | # Since there are multiple workflows, uncomment next line to ignore bower_components 366 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 367 | #bower_components/ 368 | # ASP.NET Core default setup: bower directory is configured as wwwroot/lib/ and bower restore is true 369 | **/wwwroot/lib/ 370 | 371 | # RIA/Silverlight projects 372 | Generated_Code/ 373 | 374 | # Backup & report files from converting an old project file 375 | # to a newer Visual Studio version. Backup files are not needed, 376 | # because we have git ;-) 377 | _UpgradeReport_Files/ 378 | Backup*/ 379 | UpgradeLog*.XML 380 | UpgradeLog*.htm 381 | ServiceFabricBackup/ 382 | *.rptproj.bak 383 | 384 | # SQL Server files 385 | *.mdf 386 | *.ldf 387 | *.ndf 388 | 389 | # Business Intelligence projects 390 | *.rdl.data 391 | *.bim.layout 392 | *.bim_*.settings 393 | *.rptproj.rsuser 394 | 395 | # Microsoft Fakes 396 | FakesAssemblies/ 397 | 398 | # GhostDoc plugin setting file 399 | *.GhostDoc.xml 400 | 401 | # Node.js Tools for Visual Studio 402 | .ntvs_analysis.dat 403 | node_modules/ 404 | 405 | # Visual Studio 6 build log 406 | *.plg 407 | 408 | # Visual Studio 6 workspace options file 409 | *.opt 410 | 411 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 412 | *.vbw 413 | 414 | # Visual Studio LightSwitch build output 415 | **/*.HTMLClient/GeneratedArtifacts 416 | **/*.DesktopClient/GeneratedArtifacts 417 | **/*.DesktopClient/ModelManifest.xml 418 | **/*.Server/GeneratedArtifacts 419 | **/*.Server/ModelManifest.xml 420 | _Pvt_Extensions 421 | 422 | # Paket dependency manager 423 | .paket/paket.exe 424 | paket-files/ 425 | 426 | # FAKE - F# Make 427 | .fake/ 428 | 429 | # JetBrains Rider 430 | .idea/ 431 | *.sln.iml 432 | 433 | # CodeRush personal settings 434 | .cr/personal 435 | 436 | # Python Tools for Visual Studio (PTVS) 437 | *.pyc 438 | 439 | # Cake - Uncomment if you are using it 440 | # tools/** 441 | # !tools/packages.config 442 | 443 | # Tabs Studio 444 | *.tss 445 | 446 | # Telerik's JustMock configuration file 447 | *.jmconfig 448 | 449 | # BizTalk build output 450 | *.btp.cs 451 | *.btm.cs 452 | *.odx.cs 453 | *.xsd.cs 454 | 455 | # OpenCover UI analysis results 456 | OpenCover/ 457 | 458 | # Azure Stream Analytics local run output 459 | ASALocalRun/ 460 | 461 | # MSBuild Binary and Structured Log 462 | *.binlog 463 | 464 | # NVidia Nsight GPU debugger configuration file 465 | *.nvuser 466 | 467 | # MFractors (Xamarin productivity tool) working folder 468 | .mfractor/ 469 | 470 | # Local History for Visual Studio 471 | .localhistory/ 472 | 473 | # BeatPulse healthcheck temp database 474 | healthchecksdb 475 | 476 | # End of https://www.gitignore.io/api/python,visualstudio,visualstudiocode 477 | .vscode 478 | *.gwl 479 | *Sandbox*.ipynb 480 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Ignore Jupyter notebooks and notebook links 2 | exclude: \.(ipynb|nblink)$ 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v4.4.0 6 | hooks: 7 | - id: check-merge-conflict 8 | - id: check-toml 9 | - id: check-yaml 10 | - id: end-of-file-fixer 11 | - id: trailing-whitespace 12 | - repo: https://github.com/PyCQA/isort 13 | rev: 5.12.0 14 | hooks: 15 | - id: isort 16 | name: isort 17 | - repo: https://github.com/psf/black 18 | rev: 22.3.0 19 | hooks: 20 | - id: black 21 | - repo: https://github.com/pre-commit/mirrors-mypy 22 | rev: v1.3.0 23 | hooks: 24 | - id: mypy 25 | exclude: 'test_.*?\.py$' 26 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the OS, Python version and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.12" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/source/conf.py 17 | 18 | # Optionally set the version of Python and requirements required to build your docs 19 | python: 20 | install: 21 | - requirements: docs/requirements.txt 22 | - method: pip 23 | path: . 24 | -------------------------------------------------------------------------------- /CLA.md: -------------------------------------------------------------------------------- 1 | # `robotools` CLA 2 | 3 | Thank you for your interest in contributing to Forschungszentrum Jülich GmbH - Institute for Bio- and Geosciences: IBG-1's `robotools` project ("We" or "Us"). 4 | 5 | The purpose of this contributor agreement is to clarify and document the rights granted by contributors to Us. 6 | To make this document effective, please follow the instructions below. 7 | 8 | # How to use this CLA 9 | 10 | If You are an employee and have created the Contribution as part of your employment, You need to have Your employers approval as a Legal Entity. 11 | If You do not own the Copyright in the entire work of authorship, any other author of the Contribution must also sign this. 12 | 13 | # Definitions 14 | 15 | **"You"** means the individual Copyright owner who Submits a Contribution to Us. 16 | 17 | **"Legal Entity"** means an entity that is not a natural person. 18 | 19 | **"Contribution"** means any original work of authorship, including any original modifications or additions to an existing work of authorship, Submitted by You to Us, in which You own the Copyright. 20 | 21 | **"Copyright"** means all rights protecting works of authorship, including copyright, moral and neighboring rights, as appropriate, for the full term of their existence. 22 | 23 | **"Material"** means the software or documentation made available by Us to third parties. 24 | When this Agreement covers more than one software project, the Material means the software or documentation to which the Contribution was Submitted. 25 | After You Submit the Contribution, it may be included in the Material. 26 | 27 | **"Submit"** means any act by which a Contribution is transferred to Us by You by means of tangible or intangible media, including but not limited to electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us, but excluding any transfer that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." 28 | 29 | **"Documentation"** means any non-software portion of a Contribution. 30 | 31 | # Rights and Obligations 32 | 33 | You hereby grant to Us a worldwide, royalty-free, non-exclusive, perpetual and irrevocable license, with the right to transfer an unlimited number of licenses or to grant sublicenses to third parties, under the Copyright covering the Contribution to use the Contribution by all means. 34 | 35 | We agree to (sub)license the Contribution or any Materials containing it, based on or derived from your Contribution non-exclusively under the terms of any licenses the Free Software Foundation classifies as Free Software License and which are approved by the Open Source Initiative as Open Source licenses. 36 | 37 | # Copyright 38 | 39 | You warrant, that you are legitimated to license the Contribution to us. 40 | 41 | # Acceptance of the CLA 42 | 43 | By submitting your Contribution via the [Repository](https://github.com/jubiotech/robotools) you accept this agreement. 44 | 45 | 46 | # Liability 47 | 48 | Except in cases of willful misconduct or physical damage directly caused to natural persons, the Parties will not be liable for any direct or indirect, material or moral, damages of any kind, arising out of the Licence or of the use of the Material, including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, loss of data or any commercial damage. 49 | However, the Licensor will be liable under statutory product liability laws as far such laws apply to the Material. 50 | 51 | # Warranty 52 | 53 | The Material is a work in progress, which is continuously being improved by numerous Contributors. 54 | It is not a finished work and may therefore contain defects or "bugs" inherent to this type of development. 55 | 56 | For the above reason, the Material is provided on an "as is" basis and without warranties of any kind concerning the Material, including without limitation, 57 | merchantability, fitness for a particular purpose, absence of defects or errors, accuracy, non-infringement of intellectual property rights other than copyright. 58 | 59 | # Miscellaneous 60 | 61 | This Agreement and all disputes, claims, actions, suits or other proceedings arising out of this agreement or relating in any way to it shall be governed by the laws of Germany excluding its private international law provisions. 62 | 63 | If any provision of this Agreement is found void and unenforceable, such provision will be replaced to the extent possible with a provision that comes closest to the meaning of the original provision and that is enforceable. 64 | The terms and conditions set forth in this Agreement shall apply notwithstanding any failure of essential purpose of this Agreement or any limited remedy to the maximum extent possible under law. 65 | 66 | You agree to notify Us of any facts or circumstances of which You become aware that would make this Agreement inaccurate in any respect. 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://img.shields.io/pypi/v/robotools)](https://pypi.org/project/robotools) 2 | [![pipeline](https://github.com/jubiotech/robotools/workflows/pipeline/badge.svg)](https://github.com/jubiotech/robotools/actions) 3 | [![coverage](https://codecov.io/gh/jubiotech/robotools/branch/master/graph/badge.svg)](https://codecov.io/gh/jubiotech/robotools) 4 | [![documentation](https://readthedocs.org/projects/robotools/badge/?version=latest)](https://robotools.readthedocs.io/en/latest/?badge=latest) 5 | [![DOI](https://zenodo.org/badge/358629210.svg)](https://zenodo.org/badge/latestdoi/358629210) 6 | 7 | # `robotools` 8 | 9 | This is a package for debugging and planning liquid handling operations, writing worklist files for the Tecan FreedomEVO and Tecan Fluent platform on the fly. 10 | 11 | You can visit the documentation at https://robotools.readthedocs.io, where the [notebooks](https://github.com/jubiotech/robotools/tree/master/notebooks) 12 | are rendered next to auto-generated API documentation. 13 | 14 | # Installation 15 | 16 | `robotools` is available through [PyPI](https://pypi.org/project/robotools/): 17 | 18 | ``` 19 | pip install robotools 20 | ``` 21 | 22 | # Contributing 23 | 24 | The easiest way to contribute is to report bugs by opening [Issues](https://github.com/JuBiotech/robotools/issues). 25 | 26 | We apply automated code style normalization using `black`. 27 | This is done with a `pre-commit`, which you can set up like this: 28 | 1. `pip install pre-commit` 29 | 2. `pre-commit install` 30 | 3. `pre-commit run --all` 31 | 32 | Step 2.) makes sure that the `pre-commit` runs automatically before you make a commit. 33 | 34 | Step 3.) runs it manually. 35 | 36 | # Usage and Citing 37 | 38 | `robotools` is licensed under the [GNU Affero General Public License v3.0](https://github.com/JuBiotech/robotools/blob/master/LICENSE). 39 | 40 | When using `robotools` in your work, please cite the [corresponding software version](https://doi.org/10.5281/zenodo.4697605). 41 | 42 | ```bibtex 43 | @software{robotools, 44 | author = {Michael Osthege and 45 | Laura Helleckes}, 46 | title = {JuBiotech/robotools: v1.3.0}, 47 | month = nov, 48 | year = 2021, 49 | publisher = {Zenodo}, 50 | version = {v1.3.0}, 51 | doi = {10.5281/zenodo.5745938}, 52 | url = {https://doi.org/10.5281/zenodo.5745938} 53 | } 54 | ``` 55 | 56 | Head over to Zenodo to [generate a BibTeX citation](https://zenodo.org/badge/latestdoi/358629210) for the latest release. 57 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | myst-nb 3 | numpydoc 4 | nbsphinx 5 | sphinx-book-theme 6 | sphinxcontrib.mermaid 7 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | from typing import List 17 | 18 | from robotools import __version__ 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = "robotools" 23 | copyright = "2021, Forschungszentrum Jülich GmbH" 24 | author = "Michael Osthege" 25 | 26 | # The full version, including alpha/beta/rc tags 27 | release = __version__ 28 | 29 | 30 | # -- General configuration --------------------------------------------------- 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [ 36 | "sphinx.ext.autodoc", 37 | "numpydoc", 38 | "myst_nb", 39 | "sphinx_book_theme", 40 | "sphinxcontrib.mermaid", 41 | ] 42 | nb_execution_mode = "off" 43 | 44 | # Add any paths that contain templates here, relative to this directory. 45 | templates_path = ["_templates"] 46 | 47 | # List of patterns, relative to source directory, that match files and 48 | # directories to ignore when looking for source files. 49 | # This pattern also affects html_static_path and html_extra_path. 50 | exclude_patterns: List[str] = [] 51 | 52 | 53 | # -- Options for HTML output ------------------------------------------------- 54 | 55 | # The theme to use for HTML and HTML Help pages. See the documentation for 56 | # a list of builtin themes. 57 | # 58 | html_theme = "sphinx_book_theme" 59 | 60 | # Add any paths that contain custom static files (such as style sheets) here, 61 | # relative to this directory. They are copied after the builtin static files, 62 | # so a file named "default.css" will overwrite the builtin "default.css". 63 | html_static_path = ["_static"] 64 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to the ``robotools`` documentation! 2 | =========================================== 3 | 4 | .. image:: https://img.shields.io/pypi/v/robotools 5 | :target: https://pypi.org/project/robotools 6 | 7 | .. image:: https://img.shields.io/badge/code%20on-Github-lightgrey 8 | :target: https://github.com/JuBiotech/robotools 9 | 10 | .. image:: https://zenodo.org/badge/358629210.svg 11 | :target: https://zenodo.org/badge/latestdoi/358629210 12 | 13 | 14 | ``robotools`` is a Python package for planning *in silico* liquid handling operations. 15 | 16 | It can create Tecan Freedom EVO or Tecan Fluent worklist files on the fly, making it possible to program complicated liquid handling operations 17 | from Python scripts or Jupyter notebooks. 18 | 19 | Installation 20 | ============ 21 | 22 | .. code-block:: bash 23 | 24 | pip install robotools 25 | 26 | You can also download the latest version from `GitHub `_. 27 | 28 | Tutorials 29 | ========= 30 | 31 | In the following chapters, we introduce the data structures, worklist-creation and extra features. 32 | 33 | .. toctree:: 34 | :maxdepth: 1 35 | 36 | notebooks/01_Labware_Basics 37 | notebooks/02_Worklist_Basics 38 | notebooks/03_Large_Volumes 39 | notebooks/04_Composition_Tracking 40 | notebooks/05_DilutionPlan 41 | notebooks/06_Advanced_Worklist_Commands 42 | notebooks/07_TipMasks 43 | 44 | 45 | API Reference 46 | ============= 47 | 48 | .. toctree:: 49 | :maxdepth: 2 50 | 51 | robotools_evotools 52 | robotools_fluenttools 53 | robotools_liquidhandling 54 | robotools_worklists 55 | robotools_transform 56 | robotools_utils 57 | -------------------------------------------------------------------------------- /docs/source/notebooks/01_Labware_Basics.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# `Labware` Basics\n", 8 | "In `robotools` there are two important types of objects: `Labware` and worklists.\n", 9 | "\n", 10 | "A `Labware` represents an array of wells, such as a microtiter plate (MTP), one or more troughs or even an arrangement of eppis or falcon tubes.\n", 11 | "\n", 12 | "The worklists help you to perform liquid handling operations on `Labware` objects while automatically creating a Gemini worklist file (`*.gwl`) with the corresponding pipetting instructions.\n", 13 | "These files contain things like source/destination, volume and can be executed by a Tecan EVO or Fluent liquid handlers.\n", 14 | "\n", 15 | "But before we'll get creating worklists, this example introduces how `robotools` generally deals with liquid handling." 16 | ] 17 | }, 18 | { 19 | "cell_type": "code", 20 | "execution_count": 1, 21 | "metadata": { 22 | "execution": { 23 | "iopub.execute_input": "2023-03-17T19:34:04.452677Z", 24 | "iopub.status.busy": "2023-03-17T19:34:04.451679Z", 25 | "iopub.status.idle": "2023-03-17T19:34:05.521851Z", 26 | "shell.execute_reply": "2023-03-17T19:34:05.520855Z" 27 | } 28 | }, 29 | "outputs": [], 30 | "source": [ 31 | "import robotools" 32 | ] 33 | }, 34 | { 35 | "cell_type": "markdown", 36 | "metadata": {}, 37 | "source": [ 38 | "## The `Labware` object\n", 39 | "Each `Labware` object describes a $R\\times C$ grid (array) of wells.\n", 40 | "It has a `name` and `min_volume`/`max_volume` to prevent you from pipetting impossible volumes.\n", 41 | "\n", 42 | "The following cell creates a `Labware` with the name `\"24-well plate\"`.\n", 43 | "\n", 44 | "When creating worklists, this name should match the labware defined on the liquid handler worktable." 45 | ] 46 | }, 47 | { 48 | "cell_type": "code", 49 | "execution_count": 2, 50 | "metadata": { 51 | "execution": { 52 | "iopub.execute_input": "2023-03-17T19:34:05.525850Z", 53 | "iopub.status.busy": "2023-03-17T19:34:05.524851Z", 54 | "iopub.status.idle": "2023-03-17T19:34:05.535882Z", 55 | "shell.execute_reply": "2023-03-17T19:34:05.535882Z" 56 | } 57 | }, 58 | "outputs": [ 59 | { 60 | "name": "stdout", 61 | "output_type": "stream", 62 | "text": [ 63 | "24-well plate\n", 64 | "[[300. 300. 300. 300. 300. 300.]\n", 65 | " [300. 300. 300. 300. 300. 300.]\n", 66 | " [300. 300. 300. 300. 300. 300.]\n", 67 | " [300. 300. 300. 300. 300. 300.]]\n" 68 | ] 69 | } 70 | ], 71 | "source": [ 72 | "plate = robotools.Labware(\n", 73 | " \"24-well plate\",\n", 74 | " rows=4, columns=6, \n", 75 | " min_volume=100, max_volume=1_000,\n", 76 | " initial_volumes=300\n", 77 | ")\n", 78 | "print(plate)" 79 | ] 80 | }, 81 | { 82 | "cell_type": "markdown", 83 | "metadata": {}, 84 | "source": [ 85 | "The `Labware` has a lot of useful properties.\n", 86 | "Most importantly: `wells` and `volumes`" 87 | ] 88 | }, 89 | { 90 | "cell_type": "code", 91 | "execution_count": 3, 92 | "metadata": { 93 | "execution": { 94 | "iopub.execute_input": "2023-03-17T19:34:05.580852Z", 95 | "iopub.status.busy": "2023-03-17T19:34:05.580852Z", 96 | "iopub.status.idle": "2023-03-17T19:34:05.597855Z", 97 | "shell.execute_reply": "2023-03-17T19:34:05.596852Z" 98 | } 99 | }, 100 | "outputs": [ 101 | { 102 | "data": { 103 | "text/plain": [ 104 | "array([['A01', 'A02', 'A03', 'A04', 'A05', 'A06'],\n", 105 | " ['B01', 'B02', 'B03', 'B04', 'B05', 'B06'],\n", 106 | " ['C01', 'C02', 'C03', 'C04', 'C05', 'C06'],\n", 107 | " ['D01', 'D02', 'D03', 'D04', 'D05', 'D06']], dtype=' 1000\n" 217 | ] 218 | } 219 | ], 220 | "source": [ 221 | "try:\n", 222 | " plate.add(['A01', 'B01', 'C01'], [200, 200, 2000])\n", 223 | "except robotools.VolumeViolationException as ex:\n", 224 | " print(ex)" 225 | ] 226 | }, 227 | { 228 | "cell_type": "markdown", 229 | "metadata": {}, 230 | "source": [ 231 | "Another job is to record a history of all liquid handling operations performed over the labware's lifetime:" 232 | ] 233 | }, 234 | { 235 | "cell_type": "code", 236 | "execution_count": 7, 237 | "metadata": { 238 | "execution": { 239 | "iopub.execute_input": "2023-03-17T19:34:05.646852Z", 240 | "iopub.status.busy": "2023-03-17T19:34:05.646852Z", 241 | "iopub.status.idle": "2023-03-17T19:34:05.659852Z", 242 | "shell.execute_reply": "2023-03-17T19:34:05.658976Z" 243 | } 244 | }, 245 | "outputs": [ 246 | { 247 | "name": "stdout", 248 | "output_type": "stream", 249 | "text": [ 250 | "24-well plate\n", 251 | "initial\n", 252 | "[[300. 300. 300. 300. 300. 300.]\n", 253 | " [300. 300. 300. 300. 300. 300.]\n", 254 | " [300. 300. 300. 300. 300. 300.]\n", 255 | " [300. 300. 300. 300. 300. 300.]]\n", 256 | "\n", 257 | "Add 55 µL to the center wells\n", 258 | "[[500. 300. 300. 300. 300. 300.]\n", 259 | " [500. 355. 355. 355. 355. 300.]\n", 260 | " [300. 355. 355. 355. 355. 300.]\n", 261 | " [300. 300. 300. 300. 300. 300.]]\n", 262 | "\n" 263 | ] 264 | } 265 | ], 266 | "source": [ 267 | "plate.add(plate.wells[1:-1, 1:-1], volumes=55, label=\"Add 55 µL to the center wells\")\n", 268 | "\n", 269 | "print(plate.report)" 270 | ] 271 | }, 272 | { 273 | "cell_type": "markdown", 274 | "metadata": {}, 275 | "source": [ 276 | "### Troughs\n", 277 | "\n", 278 | "Troughs are a little weird, because for the EVOware, they have multiple rows, even though it's actually just one big well.\n", 279 | "Nevertheless, a `Trough` is just a special type of `Labware` that has `virtual_rows`.\n", 280 | "For the Tecan Fluent, the `virtual_rows` setting is irrelevant.\n", 281 | "\n", 282 | "The following example emulates an arrangement of 4 troughs next to each other.\n", 283 | "They have `virtual_rows=8`, so there's enough space for 8 tips (`A01` through `H01`), but in reality it's just one well per column:" 284 | ] 285 | }, 286 | { 287 | "cell_type": "code", 288 | "execution_count": 8, 289 | "metadata": { 290 | "execution": { 291 | "iopub.execute_input": "2023-03-17T19:34:05.662882Z", 292 | "iopub.status.busy": "2023-03-17T19:34:05.661882Z", 293 | "iopub.status.idle": "2023-03-17T19:34:05.674851Z", 294 | "shell.execute_reply": "2023-03-17T19:34:05.674851Z" 295 | } 296 | }, 297 | "outputs": [ 298 | { 299 | "name": "stdout", 300 | "output_type": "stream", 301 | "text": [ 302 | "4xTrough\n", 303 | "[[20000. 10000. 10000. 10000.]]\n" 304 | ] 305 | } 306 | ], 307 | "source": [ 308 | "quadruple_trough = robotools.Trough(\n", 309 | " \"4xTrough\", 8, 4,\n", 310 | " min_volume=1000, max_volume=30_000,\n", 311 | " initial_volumes=[20_000, 10_000, 10_000, 10_000]\n", 312 | ")\n", 313 | "print(quadruple_trough)" 314 | ] 315 | }, 316 | { 317 | "cell_type": "markdown", 318 | "metadata": {}, 319 | "source": [ 320 | "Let's say we want to aspirate 11x 100 µL from the first trough, operating all 8 pipettes in parallel.\n", 321 | "So we need 8 _different_ virtual well IDs (A01-H01), but 11 in total.\n", 322 | "\n", 323 | "The `robotools.get_trough_wells` helper function returns such a well list:" 324 | ] 325 | }, 326 | { 327 | "cell_type": "code", 328 | "execution_count": 9, 329 | "metadata": { 330 | "execution": { 331 | "iopub.execute_input": "2023-03-17T19:34:05.677850Z", 332 | "iopub.status.busy": "2023-03-17T19:34:05.677850Z", 333 | "iopub.status.idle": "2023-03-17T19:34:05.691532Z", 334 | "shell.execute_reply": "2023-03-17T19:34:05.690502Z" 335 | } 336 | }, 337 | "outputs": [ 338 | { 339 | "name": "stdout", 340 | "output_type": "stream", 341 | "text": [ 342 | "\n", 343 | "Virtual wells:\n", 344 | "['A01', 'B01', 'C01', 'D01', 'E01', 'F01', 'G01', 'H01', 'A01', 'B01', 'C01']\n", 345 | "\n", 346 | "Result:\n", 347 | "4xTrough\n", 348 | "[[18900. 10000. 10000. 10000.]]\n", 349 | "\n" 350 | ] 351 | } 352 | ], 353 | "source": [ 354 | "vwells = robotools.get_trough_wells(\n", 355 | " n=11,\n", 356 | " trough_wells=quadruple_trough.wells[:, 0] # select the wells from the first trough\n", 357 | ")\n", 358 | "\n", 359 | "quadruple_trough.remove(vwells, 100)\n", 360 | "\n", 361 | "print(f\"\"\"\n", 362 | "Virtual wells:\n", 363 | "{vwells}\n", 364 | "\n", 365 | "Result:\n", 366 | "{quadruple_trough}\n", 367 | "\"\"\")" 368 | ] 369 | }, 370 | { 371 | "cell_type": "code", 372 | "execution_count": 10, 373 | "metadata": {}, 374 | "outputs": [ 375 | { 376 | "name": "stdout", 377 | "output_type": "stream", 378 | "text": [ 379 | "Last updated: 2024-04-23T13:11:50.287028+02:00\n", 380 | "\n" 381 | ] 382 | } 383 | ], 384 | "source": [ 385 | "%load_ext watermark\n", 386 | "%watermark -idu" 387 | ] 388 | } 389 | ], 390 | "metadata": { 391 | "kernelspec": { 392 | "display_name": "Python 3", 393 | "language": "python", 394 | "name": "python3" 395 | }, 396 | "language_info": { 397 | "codemirror_mode": { 398 | "name": "ipython", 399 | "version": 3 400 | }, 401 | "file_extension": ".py", 402 | "mimetype": "text/x-python", 403 | "name": "python", 404 | "nbconvert_exporter": "python", 405 | "pygments_lexer": "ipython3", 406 | "version": "3.10.8" 407 | } 408 | }, 409 | "nbformat": 4, 410 | "nbformat_minor": 2 411 | } 412 | -------------------------------------------------------------------------------- /docs/source/notebooks/03_Large_Volumes.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Large Volume Handling\n", 8 | "\n", 9 | "Pipetting robots have a maximum pipetting volume that they hand transfer in one step.\n", 10 | "At the same time the EVOware and Fluent Control require the user to break down liquid transfers that exceed this volume into smaller steps.\n", 11 | "\n", 12 | "This example shows how `robotools` automagically splits up large volumes, so you can create complex workflows without bothering about large volume handling." 13 | ] 14 | }, 15 | { 16 | "cell_type": "code", 17 | "execution_count": 6, 18 | "metadata": { 19 | "execution": { 20 | "iopub.execute_input": "2023-03-17T19:36:37.899444Z", 21 | "iopub.status.busy": "2023-03-17T19:36:37.898443Z", 22 | "iopub.status.idle": "2023-03-17T19:36:38.144702Z", 23 | "shell.execute_reply": "2023-03-17T19:36:38.141696Z" 24 | } 25 | }, 26 | "outputs": [], 27 | "source": [ 28 | "import robotools" 29 | ] 30 | }, 31 | { 32 | "cell_type": "markdown", 33 | "metadata": {}, 34 | "source": [ 35 | "## Defining labwares\n", 36 | "For this example, we'll prepare six falcon tubes with different glucose concentrations.\n", 37 | "We start by creating the `Labware`/`Trough` objects corresponding to the labwares on the worktable:" 38 | ] 39 | }, 40 | { 41 | "cell_type": "code", 42 | "execution_count": 7, 43 | "metadata": { 44 | "execution": { 45 | "iopub.execute_input": "2023-03-17T19:36:38.159702Z", 46 | "iopub.status.busy": "2023-03-17T19:36:38.156697Z", 47 | "iopub.status.idle": "2023-03-17T19:36:38.190694Z", 48 | "shell.execute_reply": "2023-03-17T19:36:38.189693Z" 49 | } 50 | }, 51 | "outputs": [ 52 | { 53 | "name": "stdout", 54 | "output_type": "stream", 55 | "text": [ 56 | "water\n", 57 | "[[20000.]]\n", 58 | "glucose\n", 59 | "[[20000.]]\n", 60 | "falcons\n", 61 | "[[0.]\n", 62 | " [0.]\n", 63 | " [0.]\n", 64 | " [0.]\n", 65 | " [0.]\n", 66 | " [0.]]\n" 67 | ] 68 | } 69 | ], 70 | "source": [ 71 | "water = robotools.Trough(\"water\", 6, 1, min_volume=5000, max_volume=100_000, initial_volumes=20_000)\n", 72 | "glucose = robotools.Trough(\"glucose\", 6, 1, min_volume=5000, max_volume=100_000, initial_volumes=20_000)\n", 73 | "\n", 74 | "# We'll prepare 6 Falcons with different glucose concentrations\n", 75 | "falcons = robotools.Labware(\"falcons\", 6, 1, min_volume=50, max_volume=15_000)\n", 76 | "\n", 77 | "print(water)\n", 78 | "print(glucose)\n", 79 | "print(falcons)" 80 | ] 81 | }, 82 | { 83 | "cell_type": "markdown", 84 | "metadata": {}, 85 | "source": [ 86 | "## Large Volume Handling\n", 87 | "\n", 88 | "With 1000 µL diluters, a Freedom EVO can handle about 950 µL. The exact number depends on the liquid class, but potentially also on device-specifics such as tubing lengths.\n", 89 | "\n", 90 | "By default, a worklist is initialized with `max_volume=950` and `auto_split=True`, resuilting in automatic splitting of pipetting volumes larger than 950 µL." 91 | ] 92 | }, 93 | { 94 | "cell_type": "code", 95 | "execution_count": 8, 96 | "metadata": { 97 | "execution": { 98 | "iopub.execute_input": "2023-03-17T19:36:38.225692Z", 99 | "iopub.status.busy": "2023-03-17T19:36:38.225692Z", 100 | "iopub.status.idle": "2023-03-17T19:36:38.237690Z", 101 | "shell.execute_reply": "2023-03-17T19:36:38.237690Z" 102 | } 103 | }, 104 | "outputs": [ 105 | { 106 | "name": "stdout", 107 | "output_type": "stream", 108 | "text": [ 109 | "falcons\n", 110 | "initial\n", 111 | "[[0.]\n", 112 | " [0.]\n", 113 | " [0.]\n", 114 | " [0.]\n", 115 | " [0.]\n", 116 | " [0.]]\n", 117 | "\n", 118 | "initial glucose (5 LVH steps)\n", 119 | "[[ 0.]\n", 120 | " [ 500.]\n", 121 | " [1000.]\n", 122 | " [1500.]\n", 123 | " [2500.]\n", 124 | " [2750.]]\n", 125 | "\n", 126 | "water up to 3 mL (8 LVH steps)\n", 127 | "[[3000.]\n", 128 | " [3000.]\n", 129 | " [3000.]\n", 130 | " [3000.]\n", 131 | " [3000.]\n", 132 | " [3000.]]\n", 133 | "\n" 134 | ] 135 | } 136 | ], 137 | "source": [ 138 | "with robotools.EvoWorklist(max_volume=950, auto_split=True) as wl:\n", 139 | " wl.transfer(\n", 140 | " glucose, glucose.wells[:, 0],\n", 141 | " falcons, falcons.wells[:, 0],\n", 142 | " [0, 500, 1000, 1500, 2500, 2750],\n", 143 | " label='initial glucose'\n", 144 | " )\n", 145 | " wl.transfer(\n", 146 | " water, water.wells[:, 0],\n", 147 | " falcons, falcons.wells[:,0],\n", 148 | " [3000, 2500, 2000, 1500, 500, 250],\n", 149 | " label='water up to 3 mL'\n", 150 | " )\n", 151 | " \n", 152 | "print(falcons.report)" 153 | ] 154 | }, 155 | { 156 | "cell_type": "markdown", 157 | "metadata": {}, 158 | "source": [ 159 | "Because the pipetting history of large volume transfers is condensed automatically, the individual pipetting steps don't show up.\n", 160 | "However, we can see them in the created worklist:" 161 | ] 162 | }, 163 | { 164 | "cell_type": "code", 165 | "execution_count": 9, 166 | "metadata": { 167 | "execution": { 168 | "iopub.execute_input": "2023-03-17T19:36:38.240692Z", 169 | "iopub.status.busy": "2023-03-17T19:36:38.240692Z", 170 | "iopub.status.idle": "2023-03-17T19:36:38.252690Z", 171 | "shell.execute_reply": "2023-03-17T19:36:38.252690Z" 172 | }, 173 | "scrolled": false 174 | }, 175 | "outputs": [ 176 | { 177 | "data": { 178 | "text/plain": [ 179 | "C;initial glucose\n", 180 | "A;glucose;;;2;;500.00;;;;\n", 181 | "D;falcons;;;2;;500.00;;;;\n", 182 | "W1;\n", 183 | "A;glucose;;;3;;500.00;;;;\n", 184 | "D;falcons;;;3;;500.00;;;;\n", 185 | "W1;\n", 186 | "A;glucose;;;4;;750.00;;;;\n", 187 | "D;falcons;;;4;;750.00;;;;\n", 188 | "W1;\n", 189 | "A;glucose;;;5;;834.00;;;;\n", 190 | "D;falcons;;;5;;834.00;;;;\n", 191 | "W1;\n", 192 | "A;glucose;;;6;;917.00;;;;\n", 193 | "D;falcons;;;6;;917.00;;;;\n", 194 | "W1;\n", 195 | "B;\n", 196 | "A;glucose;;;3;;500.00;;;;\n", 197 | "D;falcons;;;3;;500.00;;;;\n", 198 | "W1;\n", 199 | "A;glucose;;;4;;750.00;;;;\n", 200 | "D;falcons;;;4;;750.00;;;;\n", 201 | "W1;\n", 202 | "A;glucose;;;5;;834.00;;;;\n", 203 | "D;falcons;;;5;;834.00;;;;\n", 204 | "W1;\n", 205 | "A;glucose;;;6;;917.00;;;;\n", 206 | "D;falcons;;;6;;917.00;;;;\n", 207 | "W1;\n", 208 | "B;\n", 209 | "A;glucose;;;5;;832.00;;;;\n", 210 | "D;falcons;;;5;;832.00;;;;\n", 211 | "W1;\n", 212 | "A;glucose;;;6;;916.00;;;;\n", 213 | "D;falcons;;;6;;916.00;;;;\n", 214 | "W1;\n", 215 | "B;\n", 216 | "C;water up to 3 mL\n", 217 | "A;water;;;1;;750.00;;;;\n", 218 | "D;falcons;;;1;;750.00;;;;\n", 219 | "W1;\n", 220 | "A;water;;;2;;834.00;;;;\n", 221 | "D;falcons;;;2;;834.00;;;;\n", 222 | "W1;\n", 223 | "A;water;;;3;;667.00;;;;\n", 224 | "D;falcons;;;3;;667.00;;;;\n", 225 | "W1;\n", 226 | "A;water;;;4;;750.00;;;;\n", 227 | "D;falcons;;;4;;750.00;;;;\n", 228 | "W1;\n", 229 | "A;water;;;5;;500.00;;;;\n", 230 | "D;falcons;;;5;;500.00;;;;\n", 231 | "W1;\n", 232 | "A;water;;;6;;250.00;;;;\n", 233 | "D;falcons;;;6;;250.00;;;;\n", 234 | "W1;\n", 235 | "B;\n", 236 | "A;water;;;1;;750.00;;;;\n", 237 | "D;falcons;;;1;;750.00;;;;\n", 238 | "W1;\n", 239 | "A;water;;;2;;834.00;;;;\n", 240 | "D;falcons;;;2;;834.00;;;;\n", 241 | "W1;\n", 242 | "A;water;;;3;;667.00;;;;\n", 243 | "D;falcons;;;3;;667.00;;;;\n", 244 | "W1;\n", 245 | "A;water;;;4;;750.00;;;;\n", 246 | "D;falcons;;;4;;750.00;;;;\n", 247 | "W1;\n", 248 | "B;\n", 249 | "A;water;;;1;;750.00;;;;\n", 250 | "D;falcons;;;1;;750.00;;;;\n", 251 | "W1;\n", 252 | "A;water;;;2;;832.00;;;;\n", 253 | "D;falcons;;;2;;832.00;;;;\n", 254 | "W1;\n", 255 | "A;water;;;3;;666.00;;;;\n", 256 | "D;falcons;;;3;;666.00;;;;\n", 257 | "W1;\n", 258 | "B;\n", 259 | "A;water;;;1;;750.00;;;;\n", 260 | "D;falcons;;;1;;750.00;;;;\n", 261 | "W1;\n", 262 | "B;" 263 | ] 264 | }, 265 | "execution_count": 9, 266 | "metadata": {}, 267 | "output_type": "execute_result" 268 | } 269 | ], 270 | "source": [ 271 | "wl" 272 | ] 273 | }, 274 | { 275 | "cell_type": "code", 276 | "execution_count": 10, 277 | "metadata": {}, 278 | "outputs": [ 279 | { 280 | "name": "stdout", 281 | "output_type": "stream", 282 | "text": [ 283 | "The watermark extension is already loaded. To reload it, use:\n", 284 | " %reload_ext watermark\n", 285 | "Last updated: 2024-04-25T09:21:21.383190+02:00\n", 286 | "\n" 287 | ] 288 | } 289 | ], 290 | "source": [ 291 | "%load_ext watermark\n", 292 | "%watermark -idu" 293 | ] 294 | } 295 | ], 296 | "metadata": { 297 | "kernelspec": { 298 | "display_name": "Python 3", 299 | "language": "python", 300 | "name": "python3" 301 | }, 302 | "language_info": { 303 | "codemirror_mode": { 304 | "name": "ipython", 305 | "version": 3 306 | }, 307 | "file_extension": ".py", 308 | "mimetype": "text/x-python", 309 | "name": "python", 310 | "nbconvert_exporter": "python", 311 | "pygments_lexer": "ipython3", 312 | "version": "3.10.8" 313 | } 314 | }, 315 | "nbformat": 4, 316 | "nbformat_minor": 2 317 | } 318 | -------------------------------------------------------------------------------- /docs/source/notebooks/04_Composition_Tracking.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Composition Tracking\n", 8 | "`robotools` automagically tracks the composition of well contents across liquid handling operations.\n", 9 | "This comes in handy for tasks such as media mixing, dilution series, or checking if the final concentrations of assay components are as planned.\n", 10 | "\n", 11 | "The composition tracking defaults to unique well-wise identifiers, but can be configured to name contents of wells explicitly." 12 | ] 13 | }, 14 | { 15 | "cell_type": "code", 16 | "execution_count": 1, 17 | "metadata": { 18 | "execution": { 19 | "iopub.execute_input": "2023-03-17T19:39:07.440825Z", 20 | "iopub.status.busy": "2023-03-17T19:39:07.440825Z", 21 | "iopub.status.idle": "2023-03-17T19:39:07.668630Z", 22 | "shell.execute_reply": "2023-03-17T19:39:07.667724Z" 23 | } 24 | }, 25 | "outputs": [], 26 | "source": [ 27 | "import robotools" 28 | ] 29 | }, 30 | { 31 | "cell_type": "markdown", 32 | "metadata": {}, 33 | "source": [ 34 | "Let's assume we have three labwares:\n", 35 | "+ A `Trough` of water.\n", 36 | "+ A 5-column `Trough` with 10xMedia, 1000xAntibiotics and two empty columns\n", 37 | "+ Two Eppis with biomass of different microorganisms\n", 38 | "\n", 39 | "From this we will prepare culture broths in the two empty trough colums to test the effectivity of the antibiotics." 40 | ] 41 | }, 42 | { 43 | "cell_type": "code", 44 | "execution_count": 2, 45 | "metadata": { 46 | "execution": { 47 | "iopub.execute_input": "2023-03-17T19:39:07.671661Z", 48 | "iopub.status.busy": "2023-03-17T19:39:07.671661Z", 49 | "iopub.status.idle": "2023-03-17T19:39:07.684659Z", 50 | "shell.execute_reply": "2023-03-17T19:39:07.683738Z" 51 | } 52 | }, 53 | "outputs": [], 54 | "source": [ 55 | "minmax25 = dict(min_volume=1000, max_volume=25_000)\n", 56 | "\n", 57 | "water = robotools.Trough(\"water\", 1, 1, **minmax25, initial_volumes=25_000)\n", 58 | "\n", 59 | "troughs = robotools.Trough(\n", 60 | " \"troughs\", 1, columns=5, **minmax25,\n", 61 | " initial_volumes=[10_000, 5_000, 0, 0, 0],\n", 62 | " # Trough contents are named like this:\n", 63 | " column_names=[\"10xMedia\", \"100xAntibiotics\", None, None, None]\n", 64 | ")\n", 65 | "\n", 66 | "eppis = robotools.Labware(\n", 67 | " \"eppis\", 2, 1, min_volume=50, max_volume=1500,\n", 68 | " initial_volumes=500,\n", 69 | " # Multi-well labware contents are named with a dict:\n", 70 | " component_names={\n", 71 | " \"A01\": \"E.coli\",\n", 72 | " \"B01\": \"Y.pestis\",\n", 73 | " }\n", 74 | ")" 75 | ] 76 | }, 77 | { 78 | "cell_type": "markdown", 79 | "metadata": {}, 80 | "source": [ 81 | "The `Labware.composition` property is a dictionary that holds the fractional composition of each well, indexed by the name of the component:" 82 | ] 83 | }, 84 | { 85 | "cell_type": "code", 86 | "execution_count": 3, 87 | "metadata": { 88 | "execution": { 89 | "iopub.execute_input": "2023-03-17T19:39:07.687660Z", 90 | "iopub.status.busy": "2023-03-17T19:39:07.687660Z", 91 | "iopub.status.idle": "2023-03-17T19:39:07.700610Z", 92 | "shell.execute_reply": "2023-03-17T19:39:07.699611Z" 93 | } 94 | }, 95 | "outputs": [ 96 | { 97 | "data": { 98 | "text/plain": [ 99 | "{'10xMedia': array([[1., 0., 0., 0., 0.]]),\n", 100 | " '100xAntibiotics': array([[0., 1., 0., 0., 0.]])}" 101 | ] 102 | }, 103 | "execution_count": 3, 104 | "metadata": {}, 105 | "output_type": "execute_result" 106 | } 107 | ], 108 | "source": [ 109 | "troughs.composition" 110 | ] 111 | }, 112 | { 113 | "cell_type": "markdown", 114 | "metadata": {}, 115 | "source": [ 116 | "We can use two of the empty trough columns to prepare and inoculate a culture broth:" 117 | ] 118 | }, 119 | { 120 | "cell_type": "code", 121 | "execution_count": 4, 122 | "metadata": { 123 | "execution": { 124 | "iopub.execute_input": "2023-03-17T19:39:07.735610Z", 125 | "iopub.status.busy": "2023-03-17T19:39:07.735610Z", 126 | "iopub.status.idle": "2023-03-17T19:39:07.748612Z", 127 | "shell.execute_reply": "2023-03-17T19:39:07.747611Z" 128 | } 129 | }, 130 | "outputs": [], 131 | "source": [ 132 | "V_MEDIUM = 10_000\n", 133 | "V_FINAL = 4_000\n", 134 | "\n", 135 | "with robotools.FluentWorklist() as wl:\n", 136 | " wells_medium = troughs.wells[:, 2]\n", 137 | " wells_strain_A = troughs.wells[:, 3]\n", 138 | " wells_strain_B = troughs.wells[:, 4]\n", 139 | "\n", 140 | " # Prepare the medium\n", 141 | " wl.transfer(\n", 142 | " troughs, troughs.wells[:, 0],\n", 143 | " troughs, wells_medium,\n", 144 | " volumes=V_MEDIUM / 10,\n", 145 | " label=\"transfer 10x media\"\n", 146 | " )\n", 147 | " wl.transfer(\n", 148 | " troughs, troughs.wells[:, 1],\n", 149 | " troughs, wells_medium,\n", 150 | " volumes=V_MEDIUM / 100,\n", 151 | " label=\"add antibiotics\"\n", 152 | " )\n", 153 | " wl.transfer(\n", 154 | " water, water.wells,\n", 155 | " troughs, wells_medium,\n", 156 | " volumes=(V_MEDIUM * 0.95) - troughs.volumes[:, 2],\n", 157 | " label=\"add water up to 95 % of the final volume\"\n", 158 | " )\n", 159 | " \n", 160 | " # Split the medium into the two empty troughs\n", 161 | " for target in [wells_strain_A, wells_strain_B]:\n", 162 | " wl.transfer(\n", 163 | " troughs, wells_medium,\n", 164 | " troughs, target,\n", 165 | " volumes=0.95 * V_FINAL,\n", 166 | " label=\"Transfer medium\"\n", 167 | " )\n", 168 | " \n", 169 | " # Add inoculum from the eppis\n", 170 | " wl.transfer(\n", 171 | " eppis, \"A01\",\n", 172 | " troughs, wells_strain_A,\n", 173 | " volumes=0.05 * V_FINAL,\n", 174 | " label=\"Inoculate A\"\n", 175 | " )\n", 176 | " wl.transfer(\n", 177 | " eppis, \"B01\",\n", 178 | " troughs, wells_strain_B,\n", 179 | " volumes=0.05 * V_FINAL,\n", 180 | " label=\"Inoculate B\"\n", 181 | " )" 182 | ] 183 | }, 184 | { 185 | "cell_type": "markdown", 186 | "metadata": {}, 187 | "source": [ 188 | "We can see from the `.composition` property of the troughs that there are new components:" 189 | ] 190 | }, 191 | { 192 | "cell_type": "code", 193 | "execution_count": 5, 194 | "metadata": { 195 | "execution": { 196 | "iopub.execute_input": "2023-03-17T19:39:07.751612Z", 197 | "iopub.status.busy": "2023-03-17T19:39:07.750643Z", 198 | "iopub.status.idle": "2023-03-17T19:39:07.764468Z", 199 | "shell.execute_reply": "2023-03-17T19:39:07.763577Z" 200 | } 201 | }, 202 | "outputs": [ 203 | { 204 | "data": { 205 | "text/plain": [ 206 | "{'10xMedia': array([[1. , 0. , 0.10526316, 0.1 , 0.1 ]]),\n", 207 | " '100xAntibiotics': array([[0. , 1. , 0.01052632, 0.01 , 0.01 ]]),\n", 208 | " 'water': array([[0. , 0. , 0.88421053, 0.84 , 0.84 ]]),\n", 209 | " 'E.coli': array([[0. , 0. , 0. , 0.05, 0. ]]),\n", 210 | " 'Y.pestis': array([[0. , 0. , 0. , 0. , 0.05]])}" 211 | ] 212 | }, 213 | "execution_count": 5, 214 | "metadata": {}, 215 | "output_type": "execute_result" 216 | } 217 | ], 218 | "source": [ 219 | "troughs.composition" 220 | ] 221 | }, 222 | { 223 | "cell_type": "markdown", 224 | "metadata": {}, 225 | "source": [ 226 | "The composition of the individual culture broth wells is often easier to read.\n", 227 | "We can see that the 10xMedia component indeed makes up 10 % of the final volume:" 228 | ] 229 | }, 230 | { 231 | "cell_type": "code", 232 | "execution_count": 6, 233 | "metadata": { 234 | "execution": { 235 | "iopub.execute_input": "2023-03-17T19:39:07.767498Z", 236 | "iopub.status.busy": "2023-03-17T19:39:07.766497Z", 237 | "iopub.status.idle": "2023-03-17T19:39:07.780581Z", 238 | "shell.execute_reply": "2023-03-17T19:39:07.779537Z" 239 | } 240 | }, 241 | "outputs": [ 242 | { 243 | "data": { 244 | "text/plain": [ 245 | "{'10xMedia': 0.1,\n", 246 | " '100xAntibiotics': 0.009999999999999997,\n", 247 | " 'water': 0.84,\n", 248 | " 'E.coli': 0.05}" 249 | ] 250 | }, 251 | "execution_count": 6, 252 | "metadata": {}, 253 | "output_type": "execute_result" 254 | } 255 | ], 256 | "source": [ 257 | "troughs.get_well_composition(\"A04\")" 258 | ] 259 | }, 260 | { 261 | "cell_type": "code", 262 | "execution_count": 7, 263 | "metadata": { 264 | "execution": { 265 | "iopub.execute_input": "2023-03-17T19:39:07.783578Z", 266 | "iopub.status.busy": "2023-03-17T19:39:07.783578Z", 267 | "iopub.status.idle": "2023-03-17T19:39:07.795843Z", 268 | "shell.execute_reply": "2023-03-17T19:39:07.794837Z" 269 | } 270 | }, 271 | "outputs": [ 272 | { 273 | "data": { 274 | "text/plain": [ 275 | "{'10xMedia': 0.1,\n", 276 | " '100xAntibiotics': 0.009999999999999997,\n", 277 | " 'water': 0.84,\n", 278 | " 'Y.pestis': 0.05}" 279 | ] 280 | }, 281 | "execution_count": 7, 282 | "metadata": {}, 283 | "output_type": "execute_result" 284 | } 285 | ], 286 | "source": [ 287 | "troughs.get_well_composition(\"A05\")" 288 | ] 289 | }, 290 | { 291 | "cell_type": "code", 292 | "execution_count": 8, 293 | "metadata": {}, 294 | "outputs": [ 295 | { 296 | "name": "stdout", 297 | "output_type": "stream", 298 | "text": [ 299 | "Last updated: 2024-04-25T09:21:09.110232+02:00\n", 300 | "\n" 301 | ] 302 | } 303 | ], 304 | "source": [ 305 | "%load_ext watermark\n", 306 | "%watermark -idu" 307 | ] 308 | } 309 | ], 310 | "metadata": { 311 | "kernelspec": { 312 | "display_name": "Python 3", 313 | "language": "python", 314 | "name": "python3" 315 | }, 316 | "language_info": { 317 | "codemirror_mode": { 318 | "name": "ipython", 319 | "version": 3 320 | }, 321 | "file_extension": ".py", 322 | "mimetype": "text/x-python", 323 | "name": "python", 324 | "nbconvert_exporter": "python", 325 | "pygments_lexer": "ipython3", 326 | "version": "3.10.8" 327 | } 328 | }, 329 | "nbformat": 4, 330 | "nbformat_minor": 2 331 | } 332 | -------------------------------------------------------------------------------- /docs/source/notebooks/06_Advanced_Worklist_Commands.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Advanced Worklist Commands\n", 8 | "\n", 9 | "`robotools` aims to support all features of the worklist file format for Tecan EVO and Fluent systems.\n", 10 | "\n", 11 | "For example, you can `decontaminate`, `wash`, `set_ditis`, or use `aspirate_well`/`dispense_well` to customize individual steps." 12 | ] 13 | }, 14 | { 15 | "cell_type": "code", 16 | "execution_count": 1, 17 | "metadata": { 18 | "execution": { 19 | "iopub.execute_input": "2023-03-17T20:03:36.185124Z", 20 | "iopub.status.busy": "2023-03-17T20:03:36.184122Z", 21 | "iopub.status.idle": "2023-03-17T20:03:37.152708Z", 22 | "shell.execute_reply": "2023-03-17T20:03:37.151701Z" 23 | } 24 | }, 25 | "outputs": [ 26 | { 27 | "name": "stdout", 28 | "output_type": "stream", 29 | "text": [ 30 | "96-well plate\n", 31 | "[[40. 40. 10. 10. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 32 | " [ 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 33 | " [ 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 34 | " [ 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 35 | " [ 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 36 | " [ 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 37 | " [ 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]\n", 38 | " [ 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.]]\n", 39 | "C;We can add multiline-comments about our wash procedure:\n", 40 | "C;Washing scheme 3 (defined in the EVOware) is used.\n", 41 | "W3;\n", 42 | "A;Systemliquid;;System;1;;100.00;;;;\n", 43 | "D;96-well plate;;;1;;100.00;;;;\n", 44 | "WD;\n", 45 | "C;One-to-many transfer from A01 to A02-A04\n", 46 | "A;96-well plate;;;1;;10.00;MyAwesomeLiquidClass;;;\n", 47 | "D;96-well plate;;;9;;10.00;MyAwesomeLiquidClass;;;\n", 48 | "W1;\n", 49 | "A;96-well plate;;;1;;10.00;MyAwesomeLiquidClass;;;\n", 50 | "D;96-well plate;;;17;;10.00;MyAwesomeLiquidClass;;;\n", 51 | "W1;\n", 52 | "A;96-well plate;;;1;;10.00;MyAwesomeLiquidClass;;;\n", 53 | "D;96-well plate;;;25;;10.00;MyAwesomeLiquidClass;;;\n", 54 | "W1;\n", 55 | "C;Using tips 2/3/4\n", 56 | "A;96-well plate;;;1;;10.00;;;2;\n", 57 | "D;96-well plate;;;9;;10.00;;;2;\n", 58 | "W1;\n", 59 | "A;96-well plate;;;1;;10.00;;;4;\n", 60 | "D;96-well plate;;;9;;10.00;;;4;\n", 61 | "W1;\n", 62 | "A;96-well plate;;;1;;10.00;;;8;\n", 63 | "D;96-well plate;;;9;;10.00;;;8;\n", 64 | "W1;\n" 65 | ] 66 | } 67 | ], 68 | "source": [ 69 | "import robotools\n", 70 | "\n", 71 | "plate = robotools.Labware(\"96-well plate\", 8, 12, min_volume=10, max_volume=250)\n", 72 | "\n", 73 | "with robotools.FluentWorklist() as wl:\n", 74 | " wl.comment(\"\"\"\n", 75 | " We can add multiline-comments about our wash procedure:\n", 76 | " Washing scheme 3 (defined in the EVOware) is used.\n", 77 | " \"\"\")\n", 78 | " wl.wash(scheme=3)\n", 79 | "\n", 80 | " # pipetting system liquid into A01\n", 81 | " wl.aspirate_well(robotools.Labwares.SystemLiquid, position=1, volume=100, rack_type=\"System\")\n", 82 | " wl.dispense(plate, \"A01\", 100)\n", 83 | " \n", 84 | " wl.decontaminate()\n", 85 | "\n", 86 | " wl.comment(\"One-to-many transfer from A01 to A02-A04\")\n", 87 | " wl.transfer(\n", 88 | " plate, \"A01\",\n", 89 | " plate, [\"A02\", \"A03\", \"A04\"],\n", 90 | " volumes=10,\n", 91 | " liquid_class=\"MyAwesomeLiquidClass\"\n", 92 | " )\n", 93 | "\n", 94 | " wl.comment(\"Using tips 2/3/4\")\n", 95 | " wl.transfer(plate, \"A01\", plate, \"A02\", volumes=10, tip=robotools.Tip.T2)\n", 96 | " wl.transfer(plate, \"A01\", plate, \"A02\", volumes=10, tip=3)\n", 97 | " wl.transfer(plate, \"A01\", plate, \"A02\", volumes=10, tip=4)\n", 98 | " \n", 99 | "print(plate)\n", 100 | "print(wl)" 101 | ] 102 | }, 103 | { 104 | "cell_type": "code", 105 | "execution_count": 2, 106 | "metadata": {}, 107 | "outputs": [ 108 | { 109 | "name": "stdout", 110 | "output_type": "stream", 111 | "text": [ 112 | "Last updated: 2024-04-25T09:29:17.043147+02:00\n", 113 | "\n" 114 | ] 115 | } 116 | ], 117 | "source": [ 118 | "%load_ext watermark\n", 119 | "%watermark -idu" 120 | ] 121 | } 122 | ], 123 | "metadata": { 124 | "kernelspec": { 125 | "display_name": "Python 3", 126 | "language": "python", 127 | "name": "python3" 128 | }, 129 | "language_info": { 130 | "codemirror_mode": { 131 | "name": "ipython", 132 | "version": 3 133 | }, 134 | "file_extension": ".py", 135 | "mimetype": "text/x-python", 136 | "name": "python", 137 | "nbconvert_exporter": "python", 138 | "pygments_lexer": "ipython3", 139 | "version": "3.10.8" 140 | } 141 | }, 142 | "nbformat": 4, 143 | "nbformat_minor": 2 144 | } 145 | -------------------------------------------------------------------------------- /docs/source/notebooks/07_TipMasks.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Pipetting with specific tips\n", 8 | "Sometimes it is useful to specify exactly which tip to use for pipetting.\n", 9 | "This can be to avoid cross-contaminations, but is __also needed if some tips are broken/deactivated__.\n", 10 | "\n", 11 | "To restrict pipetting operations to specific tips, one can pass the `tip` kwarg.\n", 12 | "The `tip` kwarg can be used in three ways:\n", 13 | "* Use `tip=1` to use only tip number one\n", 14 | "* Alternatively `tip=robotools.Tip.T1` is the same as the above.\n", 15 | "* Or you can pass an iterable such as a `set`, `tuple` or `list`. For example: `tip=[1, 3, 4]`\n", 16 | "\n", 17 | "The code examples below show this in action." 18 | ] 19 | }, 20 | { 21 | "cell_type": "code", 22 | "execution_count": 1, 23 | "metadata": { 24 | "execution": { 25 | "iopub.execute_input": "2023-03-17T20:32:14.305888Z", 26 | "iopub.status.busy": "2023-03-17T20:32:14.304888Z", 27 | "iopub.status.idle": "2023-03-17T20:32:14.534154Z", 28 | "shell.execute_reply": "2023-03-17T20:32:14.533175Z" 29 | } 30 | }, 31 | "outputs": [], 32 | "source": [ 33 | "import robotools\n", 34 | "\n", 35 | "water = robotools.Trough(\"water\", 8, 1, min_volume=0, max_volume=100_000, initial_volumes=100_000)\n", 36 | "target = robotools.Labware(\"target\", 4, 5, min_volume=0, max_volume=300)\n", 37 | "\n", 38 | "\n", 39 | "with robotools.Worklist() as wl:\n", 40 | " wl.transfer(\n", 41 | " source=water,\n", 42 | " source_wells=robotools.get_trough_wells(10, water.wells),\n", 43 | " destination=target,\n", 44 | " # Only the 1st and 4th row\n", 45 | " destination_wells=[\n", 46 | " ['A01', 'A02', 'A03', 'A04', 'A05'],\n", 47 | " ['D01', 'D02', 'D03', 'D04', 'D05'],\n", 48 | " ],\n", 49 | " volumes=200,\n", 50 | " # Using tips 1 and 4\n", 51 | " tip=[1, 4],\n", 52 | " label=\"Transfer water with tips 1 and 4\",\n", 53 | " )" 54 | ] 55 | }, 56 | { 57 | "cell_type": "markdown", 58 | "metadata": {}, 59 | "source": [ 60 | "The volumes are as expected: Water in the first and last row 👇" 61 | ] 62 | }, 63 | { 64 | "cell_type": "code", 65 | "execution_count": 2, 66 | "metadata": { 67 | "execution": { 68 | "iopub.execute_input": "2023-03-17T20:32:14.536155Z", 69 | "iopub.status.busy": "2023-03-17T20:32:14.536155Z", 70 | "iopub.status.idle": "2023-03-17T20:32:14.548123Z", 71 | "shell.execute_reply": "2023-03-17T20:32:14.548123Z" 72 | } 73 | }, 74 | "outputs": [ 75 | { 76 | "data": { 77 | "text/plain": [ 78 | "target\n", 79 | "[[200. 200. 200. 200. 200.]\n", 80 | " [ 0. 0. 0. 0. 0.]\n", 81 | " [ 0. 0. 0. 0. 0.]\n", 82 | " [200. 200. 200. 200. 200.]]" 83 | ] 84 | }, 85 | "execution_count": 2, 86 | "metadata": {}, 87 | "output_type": "execute_result" 88 | } 89 | ], 90 | "source": [ 91 | "target" 92 | ] 93 | }, 94 | { 95 | "cell_type": "markdown", 96 | "metadata": {}, 97 | "source": [ 98 | "Internally the EVOware identifies the tips in binary using the formula $2^{\\text{tip number} - 1}$.\n", 99 | "This way it can get away with storing one number instead of a sequence of numbers:\n", 100 | "\n", 101 | "`Tip.T1` has the value $2^{1 - 1} = 1$ and `Tip.T4` has the value $2^{4 - 1} = 8$.\n", 102 | "\n", 103 | "The tip mask for using both together is the sum: $1 + 8 = 9$.\n", 104 | "\n", 105 | "If we look at the generated worklist `wl` we can see that each aspirate (A) and dispense (D) command had a tip mask of `9`." 106 | ] 107 | }, 108 | { 109 | "cell_type": "code", 110 | "execution_count": 3, 111 | "metadata": { 112 | "execution": { 113 | "iopub.execute_input": "2023-03-17T20:32:14.585152Z", 114 | "iopub.status.busy": "2023-03-17T20:32:14.584123Z", 115 | "iopub.status.idle": "2023-03-17T20:32:14.595222Z", 116 | "shell.execute_reply": "2023-03-17T20:32:14.594186Z" 117 | } 118 | }, 119 | "outputs": [ 120 | { 121 | "data": { 122 | "text/plain": [ 123 | "C;Transfer water with tips 1 and 4\n", 124 | "A;water;;;1;;200.00;;;9;\n", 125 | "D;target;;;1;;200.00;;;9;\n", 126 | "W1;\n", 127 | "A;water;;;2;;200.00;;;9;\n", 128 | "D;target;;;4;;200.00;;;9;\n", 129 | "W1;\n", 130 | "A;water;;;3;;200.00;;;9;\n", 131 | "D;target;;;5;;200.00;;;9;\n", 132 | "W1;\n", 133 | "A;water;;;4;;200.00;;;9;\n", 134 | "D;target;;;8;;200.00;;;9;\n", 135 | "W1;\n", 136 | "A;water;;;5;;200.00;;;9;\n", 137 | "D;target;;;9;;200.00;;;9;\n", 138 | "W1;\n", 139 | "A;water;;;6;;200.00;;;9;\n", 140 | "D;target;;;12;;200.00;;;9;\n", 141 | "W1;\n", 142 | "A;water;;;7;;200.00;;;9;\n", 143 | "D;target;;;13;;200.00;;;9;\n", 144 | "W1;\n", 145 | "A;water;;;8;;200.00;;;9;\n", 146 | "D;target;;;16;;200.00;;;9;\n", 147 | "W1;\n", 148 | "A;water;;;1;;200.00;;;9;\n", 149 | "D;target;;;17;;200.00;;;9;\n", 150 | "W1;\n", 151 | "A;water;;;2;;200.00;;;9;\n", 152 | "D;target;;;20;;200.00;;;9;\n", 153 | "W1;" 154 | ] 155 | }, 156 | "execution_count": 3, 157 | "metadata": {}, 158 | "output_type": "execute_result" 159 | } 160 | ], 161 | "source": [ 162 | "wl" 163 | ] 164 | }, 165 | { 166 | "cell_type": "code", 167 | "execution_count": null, 168 | "metadata": {}, 169 | "outputs": [], 170 | "source": [] 171 | } 172 | ], 173 | "metadata": { 174 | "kernelspec": { 175 | "display_name": "Python 3.8.12 ('dibecs_6.0.5')", 176 | "language": "python", 177 | "name": "python3" 178 | }, 179 | "language_info": { 180 | "codemirror_mode": { 181 | "name": "ipython", 182 | "version": 3 183 | }, 184 | "file_extension": ".py", 185 | "mimetype": "text/x-python", 186 | "name": "python", 187 | "nbconvert_exporter": "python", 188 | "pygments_lexer": "ipython3", 189 | "version": "3.8.16" 190 | }, 191 | "vscode": { 192 | "interpreter": { 193 | "hash": "47708a100f5128723730fc4374c087f02a011f1146e5886fec6a9b8dd97015c0" 194 | } 195 | } 196 | }, 197 | "nbformat": 4, 198 | "nbformat_minor": 2 199 | } 200 | -------------------------------------------------------------------------------- /docs/source/robotools_evotools.rst: -------------------------------------------------------------------------------- 1 | robotools.evotools 2 | ------------------ 3 | 4 | .. automodule:: robotools.evotools.commands 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | .. automodule:: robotools.evotools.types 10 | :members: 11 | :undoc-members: 12 | :show-inheritance: 13 | 14 | .. automodule:: robotools.evotools.utils 15 | :members: 16 | :undoc-members: 17 | :show-inheritance: 18 | 19 | .. automodule:: robotools.evotools.worklist 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | -------------------------------------------------------------------------------- /docs/source/robotools_fluenttools.rst: -------------------------------------------------------------------------------- 1 | robotools.fluenttools 2 | --------------------- 3 | 4 | .. automodule:: robotools.fluenttools.worklist 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | .. automodule:: robotools.fluenttools.utils 10 | :members: 11 | :undoc-members: 12 | :show-inheritance: 13 | -------------------------------------------------------------------------------- /docs/source/robotools_liquidhandling.rst: -------------------------------------------------------------------------------- 1 | robotools.liquidhandling 2 | ------------------------ 3 | 4 | .. automodule:: robotools.liquidhandling.composition 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | .. automodule:: robotools.liquidhandling.exceptions 10 | :members: 11 | :undoc-members: 12 | :show-inheritance: 13 | 14 | .. automodule:: robotools.liquidhandling.labware 15 | :members: 16 | :undoc-members: 17 | :show-inheritance: 18 | -------------------------------------------------------------------------------- /docs/source/robotools_transform.rst: -------------------------------------------------------------------------------- 1 | robotools.transform 2 | ------------------- 3 | 4 | .. automodule:: robotools.transform 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/robotools_utils.rst: -------------------------------------------------------------------------------- 1 | robotools.utils 2 | --------------- 3 | 4 | .. automodule:: robotools.utils 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/source/robotools_worklists.rst: -------------------------------------------------------------------------------- 1 | robotools.worklists 2 | ------------------- 3 | 4 | .. automodule:: robotools.worklists.base 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | .. automodule:: robotools.worklists.exceptions 10 | :members: 11 | :undoc-members: 12 | :show-inheritance: 13 | 14 | .. automodule:: robotools.worklists.utils 15 | :members: 16 | :undoc-members: 17 | :show-inheritance: 18 | -------------------------------------------------------------------------------- /notebooks/README.md: -------------------------------------------------------------------------------- 1 | # Notebooks Moved 2 | 3 | Visit [docs/source/notebooks](../docs/source/notebooks). 4 | 5 | Or visit https://robotools.readthedocs.io. 6 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "robotools" 7 | version = "1.12.0" 8 | description = "Pythonic in-silico liquid handling and creation of Tecan FreedomEVO worklists." 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = {text = "GNU Affero General Public License v3"} 12 | authors = [ 13 | {name = "Michael Osthege", email = "m.osthege@fz-juelich.de"}, 14 | ] 15 | classifiers = [ 16 | "Programming Language :: Python", 17 | "Operating System :: OS Independent", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "License :: OSI Approved :: GNU Affero General Public License v3", 24 | "Intended Audience :: Science/Research", 25 | "Topic :: Scientific/Engineering", 26 | ] 27 | dependencies = [ 28 | "numpy", 29 | ] 30 | 31 | [project.urls] 32 | Homepage = "https://github.com/jubiotech/robotools" 33 | Documentation = "https://robotools.readthedocs.io/en/latest/" 34 | Download = "https://pypi.org/project/robotools/" 35 | 36 | [tool.setuptools] 37 | package-dir = {"robotools" = "robotools"} 38 | 39 | [tool.setuptools.package-data] 40 | "robotools" = ["py.typed"] 41 | 42 | [tool.black] 43 | line-length = 110 44 | 45 | [tool.isort] 46 | profile = "black" 47 | 48 | [tool.mypy] 49 | ignore_missing_imports = true 50 | exclude = [ 51 | 'test_.*?\.py$', 52 | ] 53 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | codecov 2 | flake8 3 | pytest 4 | pytest-cov 5 | twine 6 | wheel 7 | build 8 | -------------------------------------------------------------------------------- /robotools/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | 3 | from . import evotools, liquidhandling 4 | from .evotools import EvoWorklist, InvalidOperationError, Labwares, Tip, Worklist 5 | from .evotools import commands as evo_cmd 6 | from .evotools import int_to_tip 7 | from .fluenttools import FluentWorklist 8 | from .liquidhandling import ( 9 | Labware, 10 | Trough, 11 | VolumeOverflowError, 12 | VolumeUnderflowError, 13 | VolumeViolationException, 14 | ) 15 | from .transform import ( 16 | WellRandomizer, 17 | WellRotator, 18 | WellShifter, 19 | make_well_array, 20 | make_well_index_dict, 21 | ) 22 | from .utils import DilutionPlan, get_trough_wells 23 | from .worklists import BaseWorklist, CompatibilityError 24 | 25 | __version__ = importlib.metadata.version(__package__ or __name__) 26 | __all__ = ( 27 | "BaseWorklist", 28 | "CompatibilityError", 29 | "DilutionPlan", 30 | "evo_cmd", 31 | "evotools", 32 | "EvoWorklist", 33 | "FluentWorklist", 34 | "get_trough_wells", 35 | "int_to_tip", 36 | "InvalidOperationError", 37 | "Labware", 38 | "Labwares", 39 | "liquidhandling", 40 | "make_well_array", 41 | "make_well_index_dict", 42 | "Tip", 43 | "Trough", 44 | "VolumeOverflowError", 45 | "VolumeUnderflowError", 46 | "VolumeViolationException", 47 | "WellRandomizer", 48 | "WellRotator", 49 | "WellShifter", 50 | "Worklist", 51 | ) 52 | -------------------------------------------------------------------------------- /robotools/evotools/__init__.py: -------------------------------------------------------------------------------- 1 | from robotools.evotools.types import Labwares, Tip, int_to_tip 2 | from robotools.evotools.utils import get_well_position 3 | from robotools.evotools.worklist import EvoWorklist, Worklist 4 | from robotools.worklists.exceptions import InvalidOperationError 5 | -------------------------------------------------------------------------------- /robotools/evotools/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from robotools import Labware, Trough 4 | from robotools.evotools import utils 5 | 6 | 7 | def test_get_well_position(): 8 | plate = Labware("plate", 3, 4, min_volume=0, max_volume=50) 9 | assert utils.get_well_position(plate, "A01") == 1 10 | assert utils.get_well_position(plate, "B01") == 2 11 | assert utils.get_well_position(plate, "B04") == 11 12 | 13 | trough = Trough("trough", 2, 3, min_volume=0, max_volume=50) 14 | assert utils.get_well_position(trough, "A01") == 1 15 | assert utils.get_well_position(trough, "B01") == 2 16 | assert utils.get_well_position(trough, "A02") == 3 17 | assert utils.get_well_position(trough, "A03") == 5 18 | 19 | with pytest.raises(ValueError, match="not an alphanumeric well ID"): 20 | utils.get_well_position(trough, "A-3") 21 | 22 | # Currently not implemented at the Labware level: 23 | # megaplate = Labware("mplate", 50, 3, min_volume=0, max_volume=50) 24 | # assert utils.get_well_position(megaplate, "AA2") == 51 25 | pass 26 | -------------------------------------------------------------------------------- /robotools/evotools/types.py: -------------------------------------------------------------------------------- 1 | """Enums and classes, and related helper functions.""" 2 | import enum 3 | 4 | __all__ = ( 5 | "Labwares", 6 | "Tip", 7 | "int_to_tip", 8 | ) 9 | 10 | 11 | class Labwares(str, enum.Enum): 12 | """Built-in EVOware labware identifiers.""" 13 | 14 | SystemLiquid = "Systemliquid" 15 | 16 | 17 | class Tip(enum.IntEnum): 18 | """Enumeration of LiHa tip IDs.""" 19 | 20 | Any = -1 21 | T1 = 1 22 | T2 = 2 23 | T3 = 4 24 | T4 = 8 25 | T5 = 16 26 | T6 = 32 27 | T7 = 64 28 | T8 = 128 29 | 30 | 31 | def int_to_tip(tip_int: int) -> Tip: 32 | """Checks and convert a tip number [1-8] to the Tecan Tip ID.""" 33 | if tip_int == 1: 34 | return Tip.T1 35 | elif tip_int == 2: 36 | return Tip.T2 37 | elif tip_int == 3: 38 | return Tip.T3 39 | elif tip_int == 4: 40 | return Tip.T4 41 | elif tip_int == 5: 42 | return Tip.T5 43 | elif tip_int == 6: 44 | return Tip.T6 45 | elif tip_int == 7: 46 | return Tip.T7 47 | elif tip_int == 8: 48 | return Tip.T8 49 | raise ValueError( 50 | f"Tip is {tip_int} with type {type(tip_int)}, but should be an int between 1 and 8 for _int_to_tip conversion." 51 | ) 52 | -------------------------------------------------------------------------------- /robotools/evotools/utils.py: -------------------------------------------------------------------------------- 1 | """Generic utility functions.""" 2 | import re 3 | 4 | from robotools.liquidhandling import Labware 5 | 6 | 7 | def to_hex(dec: int): 8 | """Method from stackoverflow to convert decimal to hex. 9 | Link: https://stackoverflow.com/questions/5796238/python-convert-decimal-to-hex 10 | Solution posted by user "Chunghee Kim" on 21.11.2020. 11 | """ 12 | digits = "0123456789ABCDEF" 13 | x = dec % 16 14 | rest = dec // 16 15 | if rest == 0: 16 | return digits[x] 17 | return to_hex(rest) + digits[x] 18 | 19 | 20 | _WELLID_MATCHER = re.compile(r"^([a-zA-Z]+?)(\d+?)$") 21 | """Compiled RegEx for matching well row & column from alphanumeric IDs.""" 22 | 23 | 24 | def get_well_position(labware: Labware, well: str) -> int: 25 | """Calculate the EVO-style well position from the alphanumeric ID.""" 26 | # Extract row & column number from the alphanumeric ID 27 | m = _WELLID_MATCHER.match(well) 28 | if m is None: 29 | raise ValueError(f"This is not an alphanumeric well ID: '{well}'.") 30 | row = m.group(1) 31 | column = int(m.group(2)) 32 | 33 | r = labware.row_ids.index(row) 34 | c = labware.column_ids.index(column) 35 | 36 | # Calculate the position from the row & column number. 37 | # The EVO counts virtual rows in troughs too. 38 | if labware.virtual_rows is not None: 39 | return 1 + c * labware.virtual_rows + r 40 | return 1 + c * labware.n_rows + r 41 | -------------------------------------------------------------------------------- /robotools/evotools/worklist.py: -------------------------------------------------------------------------------- 1 | """ Creating worklist files for the Tecan Freedom EVO. 2 | """ 3 | import logging 4 | import textwrap 5 | import warnings 6 | from typing import Dict, List, Literal, Optional, Sequence, Tuple, Union 7 | 8 | import numpy as np 9 | 10 | from robotools import liquidhandling 11 | from robotools.evotools import commands 12 | from robotools.evotools.types import Tip 13 | from robotools.evotools.utils import get_well_position 14 | from robotools.worklists.base import BaseWorklist 15 | from robotools.worklists.utils import ( 16 | optimize_partition_by, 17 | partition_by_column, 18 | partition_volume, 19 | ) 20 | 21 | __all__ = ("EvoWorklist", "Worklist") 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | 26 | class EvoWorklist(BaseWorklist): 27 | """Context manager for the creation of Tecan EVO worklists.""" 28 | 29 | def _get_well_position(self, labware: liquidhandling.Labware, well: str) -> int: 30 | return get_well_position(labware, well) 31 | 32 | def evo_aspirate( 33 | self, 34 | labware: liquidhandling.Labware, 35 | wells: Union[str, List[str]], 36 | labware_position: Tuple[int, int], 37 | tips: Union[List[Tip], List[int]], 38 | volumes: Union[float, List[float]], 39 | liquid_class: str, 40 | *, 41 | arm: int = 0, 42 | label: Optional[str] = None, 43 | ) -> None: 44 | """Performs aspiration from the provided labware. Is identical to the aspirate command inside the EvoWARE. 45 | Thus, several wells in a single column can be targeted. 46 | 47 | Parameters 48 | ---------- 49 | labware : liquidhandling.Labware 50 | Source labware 51 | labware_position : tuple 52 | Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2) 53 | wells : list of str or iterable 54 | List with target well ID(s) 55 | tips : list 56 | Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8 57 | volumes : float or iterable 58 | Volume(s) in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases 59 | liquid_class : str, optional 60 | Overwrites the liquid class for this step (max 32 characters) 61 | arm : int 62 | Which LiHa to use, if more than one is available 63 | label : str 64 | Label of the operation to log into labware history 65 | """ 66 | # diferentiate between what is needed for volume calculation and for pipetting commands 67 | wells_calc = np.array(wells).flatten("F") 68 | volumes_calc = np.array(volumes).flatten("F") 69 | if len(volumes_calc) == 1: 70 | volumes_calc = np.repeat(volumes_calc, len(wells_calc)) 71 | labware.remove(wells_calc, volumes_calc, label) 72 | self.comment(label) 73 | cmd = commands.evo_aspirate( 74 | n_rows=labware.n_rows, 75 | n_columns=labware.n_columns, 76 | wells=wells, 77 | labware_position=labware_position, 78 | volume=volumes, 79 | liquid_class=liquid_class, 80 | tips=tips, 81 | arm=arm, 82 | max_volume=self.max_volume, 83 | ) 84 | self.append(cmd) 85 | return 86 | 87 | def evo_dispense( 88 | self, 89 | labware: liquidhandling.Labware, 90 | wells: Union[str, List[str]], 91 | labware_position: Tuple[int, int], 92 | tips: Union[List[Tip], List[int]], 93 | volumes: Union[float, List[float]], 94 | liquid_class: str, 95 | *, 96 | arm: int = 0, 97 | label: Optional[str] = None, 98 | compositions: Optional[List[Optional[Dict[str, float]]]] = None, 99 | ) -> None: 100 | """Performs dispensation from the provided labware. Is identical to the dispense command inside the EvoWARE. 101 | Thus, several wells in a single column can be targeted. 102 | 103 | Parameters 104 | ---------- 105 | labware : liquidhandling.Labware 106 | Source labware 107 | labware_position : tuple 108 | Grid position of the target labware on the robotic deck and site position on its carrier, e.g. labware on grid 38, site 2 -> (38,2) 109 | wells : list of str or iterable 110 | List with target well ID(s) 111 | tips : list 112 | Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8 113 | volumes : float or iterable 114 | Volume(s) in microliters (will be rounded to 2 decimal places); if several tips are used, these tips may aspirate individual volumes -> use list in these cases 115 | liquid_class : str, optional 116 | Overwrites the liquid class for this step (max 32 characters) 117 | arm : int 118 | Which LiHa to use, if more than one is available 119 | label : str 120 | Label of the operation to log into labware history 121 | compositions : list 122 | Iterable of liquid compositions 123 | """ 124 | # diferentiate between what is needed for volume calculation and for pipetting commands 125 | wells_calc = np.array(wells).flatten("F") 126 | volumes_calc = np.array(volumes).flatten("F") 127 | if len(volumes_calc) == 1: 128 | volumes_calc = np.repeat(volumes_calc, len(wells_calc)) 129 | labware.add(wells_calc, volumes_calc, label, compositions=compositions) 130 | self.comment(label) 131 | cmd = commands.evo_dispense( 132 | n_rows=labware.n_rows, 133 | n_columns=labware.n_columns, 134 | wells=wells, 135 | labware_position=labware_position, 136 | volume=volumes, 137 | liquid_class=liquid_class, 138 | tips=tips, 139 | arm=arm, 140 | max_volume=self.max_volume, 141 | ) 142 | self.append(cmd) 143 | return 144 | 145 | def evo_wash( 146 | self, 147 | *, 148 | tips: Union[List[Tip], List[int]], 149 | waste_location: Tuple[int, int], 150 | cleaner_location: Tuple[int, int], 151 | arm: int = 0, 152 | waste_vol: float = 3.0, 153 | waste_delay: int = 500, 154 | cleaner_vol: float = 4.0, 155 | cleaner_delay: int = 500, 156 | airgap: int = 10, 157 | airgap_speed: int = 70, 158 | retract_speed: int = 30, 159 | fastwash: int = 1, 160 | low_volume: int = 0, 161 | ) -> None: 162 | """Command for aspirating with the EvoWARE aspirate command. As many wells in one column may be selected as your liquid handling arm has pipettes. 163 | This method generates the full command (as can be observed when opening a .esc file with an editor) and calls upon other functions to create the code string 164 | specifying the target wells. 165 | 166 | Parameters 167 | ---------- 168 | tips : list 169 | Tip(s) that will be selected; use either a list with integers from 1 - 8 or with tip.T1 - tip.T8 170 | waste_location : tuple 171 | Tuple with grid position (1-67) and site number (0-127) of waste as integers 172 | cleaner_location : tuple 173 | Tuple with grid position (1-67) and site number (0-127) of cleaner as integers 174 | arm : int 175 | number of the LiHa performing the action: 0 = LiHa 1, 1 = LiHa 2 176 | waste_vol: float 177 | Volume in waste in mL (0-100) 178 | waste_delay : int 179 | Delay before closing valves in waste in ms (0-1000) 180 | cleaner_vol: float 181 | Volume in cleaner in mL (0-100) 182 | cleaner_delay : int 183 | Delay before closing valves in cleaner in ms (0-1000) 184 | airgap : int 185 | Volume of airgap in µL which is aspirated after washing the tips (system trailing airgap) (0-100) 186 | airgap_speed : int 187 | Speed of airgap aspiration in µL/s (1-1000) 188 | retract_speed : int 189 | Retract speed in mm/s (1-100) 190 | fastwash : int 191 | Use fast-wash module = 1, don't use it = 0 192 | low_volume : int 193 | Use pinch valves = 1, don't use them = 0 194 | """ 195 | cmd = commands.evo_wash( 196 | tips=tips, 197 | waste_location=waste_location, 198 | cleaner_location=cleaner_location, 199 | arm=arm, 200 | waste_vol=waste_vol, 201 | waste_delay=waste_delay, 202 | cleaner_vol=cleaner_vol, 203 | cleaner_delay=cleaner_delay, 204 | airgap=airgap, 205 | airgap_speed=airgap_speed, 206 | retract_speed=retract_speed, 207 | fastwash=fastwash, 208 | low_volume=low_volume, 209 | ) 210 | self.append(cmd) 211 | return 212 | 213 | def transfer( 214 | self, 215 | source: liquidhandling.Labware, 216 | source_wells: Union[str, Sequence[str], np.ndarray], 217 | destination: liquidhandling.Labware, 218 | destination_wells: Union[str, Sequence[str], np.ndarray], 219 | volumes: Union[float, Sequence[float], np.ndarray], 220 | *, 221 | label: Optional[str] = None, 222 | wash_scheme: Literal[1, 2, 3, 4, "flush", "reuse"] = 1, 223 | partition_by: str = "auto", 224 | **kwargs, 225 | ) -> None: 226 | """Transfer operation between two labwares. 227 | 228 | Parameters 229 | ---------- 230 | source : liquidhandling.Labware 231 | Source labware 232 | source_wells : str or iterable 233 | List of source well ids 234 | destination : liquidhandling.Labware 235 | Destination labware 236 | destination_wells : str or iterable 237 | List of destination well ids 238 | volumes : float or iterable 239 | Volume(s) to transfer 240 | label : str 241 | Label of the operation to log into labware history 242 | wash_scheme 243 | - One of ``{1, 2, 3, 4}`` to select a wash scheme for fixed tips, 244 | or drop tips when using DiTis. 245 | - ``"flush"`` blows out tips, but does not drop DiTis, and only does a short wash with fixed tips. 246 | - ``"reuse"`` continues pipetting without flushing, dropping or washing. 247 | Passing ``None`` is deprecated, results in ``"reuse"`` behavior and emits a warning. 248 | 249 | partition_by : str 250 | one of 'auto' (default), 'source' or 'destination' 251 | 'auto': partitioning by source unless the source is a Trough 252 | 'source': partitioning by source columns 253 | 'destination': partitioning by destination columns 254 | kwargs 255 | Additional keyword arguments to pass to aspirate and dispense. 256 | Most prominent example: `liquid_class`. 257 | Take a look at `Worklist.aspirate_well` for the full list of options. 258 | """ 259 | # reformat the convenience parameters 260 | source_wells = np.array(source_wells).flatten("F") 261 | destination_wells = np.array(destination_wells).flatten("F") 262 | volumes = np.array(volumes).flatten("F") 263 | nmax = max((len(source_wells), len(destination_wells), len(volumes))) 264 | 265 | # Deal with deprecated behavior 266 | if wash_scheme is None: 267 | warnings.warn( 268 | "wash_scheme=None is deprecated. For tip reuse pass 'reuse'.", 269 | DeprecationWarning, 270 | stacklevel=2, 271 | ) 272 | wash_scheme = "reuse" 273 | 274 | if len(source_wells) == 1: 275 | source_wells = np.repeat(source_wells, nmax) 276 | if len(destination_wells) == 1: 277 | destination_wells = np.repeat(destination_wells, nmax) 278 | if len(volumes) == 1: 279 | volumes = np.repeat(volumes, nmax) 280 | lengths = (len(source_wells), len(destination_wells), len(volumes)) 281 | assert ( 282 | len(set(lengths)) == 1 283 | ), f"Number of source/destination/volumes must be equal. They were {lengths}" 284 | 285 | # automatic partitioning 286 | partition_by = optimize_partition_by(source, destination, partition_by, label) 287 | 288 | # the label applies to the entire transfer operation and is not logged at individual aspirate/dispense steps 289 | self.comment(label) 290 | nsteps = 0 291 | lvh_extra = 0 292 | 293 | for srcs, dsts, vols in partition_by_column(source_wells, destination_wells, volumes, partition_by): 294 | # make vector of volumes into vector of volume-lists 295 | vol_lists = [ 296 | partition_volume(float(v), max_volume=self.max_volume) if self.auto_split else [v] 297 | for v in vols 298 | ] 299 | # transfer from this source column until all wells are done 300 | npartitions = max(map(len, vol_lists)) 301 | # Count only the extra steps created by LVH 302 | lvh_extra += sum([len(vs) - 1 for vs in vol_lists]) 303 | for p in range(npartitions): 304 | naccessed = 0 305 | # iterate the rows 306 | for s, d, vs in zip(srcs, dsts, vol_lists): 307 | # transfer the next volume-fraction for this well 308 | if len(vs) > p: 309 | v = vs[p] 310 | if v > 0: 311 | self.aspirate(source, s, v, label=None, **kwargs) 312 | self.dispense( 313 | destination, 314 | d, 315 | v, 316 | label=None, 317 | compositions=[source.get_well_composition(s)], 318 | **kwargs, 319 | ) 320 | nsteps += 1 321 | if wash_scheme == "flush": 322 | self.flush() 323 | elif wash_scheme == "reuse": 324 | pass 325 | else: 326 | self.wash(scheme=wash_scheme) 327 | naccessed += 1 328 | # LVH: if multiple wells are accessed, don't group across partitions 329 | if npartitions > 1 and naccessed > 1 and not p == npartitions - 1: 330 | self.commit() 331 | # LVH: don't group across columns 332 | if npartitions > 1: 333 | self.commit() 334 | 335 | # Condense the labware logs into one operation 336 | # after the transfer operation completed to facilitate debugging. 337 | # Also include the number of extra steps because of LVH if applicable. 338 | if lvh_extra: 339 | if label: 340 | label = f"{label} ({lvh_extra} LVH steps)" 341 | else: 342 | label = f"{lvh_extra} LVH steps" 343 | if destination == source: 344 | source.condense_log(nsteps * 2, label=label) 345 | else: 346 | source.condense_log(nsteps, label=label) 347 | destination.condense_log(nsteps, label=label) 348 | return 349 | 350 | 351 | class Worklist(EvoWorklist): 352 | def __init__(self, *args, **kwargs) -> None: 353 | msg = textwrap.dedent( 354 | """ 355 | Robotools now distunguishes between EVO- and Fluent-compatible worklists. 356 | You created a 'Worklist', which will stop working in a future release. 357 | Instead please switch to one of the following options: 358 | 1.) `robotools.EvoWorklist(...)` for EVO-compatible worklists. 359 | 2.) `robotools.FluentWorklist(...)` for Fluent-compatible worklists. 360 | 3.) `robotools.BaseWorklist(...)` for cross-compatible worklists with fewer features. 361 | """ 362 | ) 363 | warnings.warn(msg, DeprecationWarning, stacklevel=2) 364 | super().__init__(*args, **kwargs) 365 | -------------------------------------------------------------------------------- /robotools/fluenttools/__init__.py: -------------------------------------------------------------------------------- 1 | from robotools.fluenttools.utils import get_well_position 2 | from robotools.fluenttools.worklist import FluentWorklist 3 | -------------------------------------------------------------------------------- /robotools/fluenttools/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from robotools import Labware, Trough 4 | from robotools.fluenttools import utils 5 | 6 | 7 | def test_get_well_position(): 8 | plate = Labware("plate", 3, 4, min_volume=0, max_volume=50) 9 | assert utils.get_well_position(plate, "A01") == 1 10 | assert utils.get_well_position(plate, "B01") == 2 11 | assert utils.get_well_position(plate, "B04") == 11 12 | 13 | trough = Trough("trough", 2, 3, min_volume=0, max_volume=50) 14 | assert utils.get_well_position(trough, "A01") == 1 15 | assert utils.get_well_position(trough, "B01") == 1 16 | assert utils.get_well_position(trough, "A02") == 2 17 | assert utils.get_well_position(trough, "A03") == 3 18 | 19 | with pytest.raises(ValueError, match="not an alphanumeric well ID"): 20 | utils.get_well_position(trough, "🧨") 21 | 22 | # Currently not implemented at the Labware level: 23 | # megaplate = Labware("mplate", 50, 3, min_volume=0, max_volume=50) 24 | # assert utils.get_well_position(megaplate, "AA2") == 51 25 | pass 26 | -------------------------------------------------------------------------------- /robotools/fluenttools/test_worklist.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from robotools.fluenttools.worklist import FluentWorklist 4 | from robotools.liquidhandling.labware import Labware 5 | 6 | 7 | class TestFluentWorklist: 8 | def test_transfer(self): 9 | A = Labware("A", 3, 4, min_volume=10, max_volume=200) 10 | A.add("A01", 100) 11 | with FluentWorklist() as wl: 12 | wl.transfer( 13 | A, 14 | "A01", 15 | A, 16 | "B01", 17 | 50, 18 | ) 19 | assert len(wl) == 3 20 | a, d, w = wl 21 | assert a.startswith("A;") 22 | assert d.startswith("D;") 23 | assert w == "W1;" 24 | assert A.volumes[0, 0] == 50 25 | pass 26 | 27 | def test_input_checks(self): 28 | A = Labware("A", 3, 4, min_volume=10, max_volume=200, initial_volumes=150) 29 | with FluentWorklist() as wl: 30 | with pytest.raises(ValueError, match="must be equal"): 31 | wl.transfer(A, ["A01", "B01", "C01"], A, ["A01", "B01"], 20) 32 | with pytest.raises(ValueError, match="must be equal"): 33 | wl.transfer(A, ["A01", "B01"], A, ["A01", "B01", "C01"], 20) 34 | with pytest.raises(ValueError, match="must be equal"): 35 | wl.transfer(A, ["A01", "B01"], A, "A01", [30, 40, 25]) 36 | pass 37 | 38 | def test_transfer_flush(self): 39 | A = Labware("A", 3, 4, min_volume=10, max_volume=200, initial_volumes=150) 40 | with FluentWorklist() as wl: 41 | wl.transfer(A, "A01", A, "B01", 20, wash_scheme="flush") 42 | assert len(wl) == 3 43 | assert wl[-1] == "F;" 44 | pass 45 | -------------------------------------------------------------------------------- /robotools/fluenttools/utils.py: -------------------------------------------------------------------------------- 1 | """Generic utility functions.""" 2 | import re 3 | 4 | from robotools.liquidhandling import Labware 5 | 6 | _WELLID_MATCHER = re.compile(r"^([a-zA-Z]+?)(\d+?)$") 7 | """Compiled RegEx for matching well row & column from alphanumeric IDs.""" 8 | 9 | 10 | def get_well_position(labware: Labware, well: str) -> int: 11 | """Calculate the EVO-style well position from the alphanumeric ID.""" 12 | # Extract row & column number from the alphanumeric ID 13 | m = _WELLID_MATCHER.match(well) 14 | if m is None: 15 | raise ValueError(f"This is not an alphanumeric well ID: '{well}'.") 16 | row = m.group(1) 17 | column = int(m.group(2)) 18 | 19 | c = labware.column_ids.index(column) 20 | 21 | # The Fluent does NOT count rows inside troughs! 22 | if labware.is_trough: 23 | return 1 + c 24 | 25 | # Therefore the row number is only relevant for non-trough labware. 26 | row = well[0] 27 | r = labware.row_ids.index(row) 28 | return 1 + c * labware.n_rows + r 29 | -------------------------------------------------------------------------------- /robotools/fluenttools/worklist.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from collections.abc import Sequence 3 | from pathlib import Path 4 | from typing import Literal, Optional, Union 5 | 6 | import numpy as np 7 | 8 | from robotools.fluenttools.utils import get_well_position 9 | from robotools.liquidhandling.labware import Labware 10 | from robotools.worklists.base import BaseWorklist 11 | from robotools.worklists.utils import ( 12 | optimize_partition_by, 13 | partition_by_column, 14 | partition_volume, 15 | ) 16 | 17 | __all__ = ("FluentWorklist",) 18 | 19 | 20 | class FluentWorklist(BaseWorklist): 21 | """Context manager for the creation of Tecan Fluent worklists.""" 22 | 23 | def __init__( 24 | self, 25 | filepath: Optional[Union[str, Path]] = None, 26 | max_volume: Union[int, float] = 950, 27 | auto_split: bool = True, 28 | diti_mode: bool = False, 29 | ) -> None: 30 | super().__init__(filepath, max_volume, auto_split, diti_mode) 31 | 32 | def _get_well_position(self, labware: Labware, well: str) -> int: 33 | return get_well_position(labware, well) 34 | 35 | def transfer( 36 | self, 37 | source: Labware, 38 | source_wells: Union[str, Sequence[str], np.ndarray], 39 | destination: Labware, 40 | destination_wells: Union[str, Sequence[str], np.ndarray], 41 | volumes: Union[float, Sequence[float], np.ndarray], 42 | *, 43 | label: Optional[str] = None, 44 | wash_scheme: Literal[1, 2, 3, 4, "flush", "reuse"] = 1, 45 | partition_by: str = "auto", 46 | **kwargs, 47 | ) -> None: 48 | """Transfer operation between two labwares. 49 | 50 | Parameters 51 | ---------- 52 | source 53 | Source labware 54 | source_wells 55 | List of source well ids 56 | destination 57 | Destination labware 58 | destination_wells 59 | List of destination well ids 60 | volumes 61 | Volume(s) to transfer 62 | label 63 | Label of the operation to log into labware history 64 | wash_scheme 65 | - One of ``{1, 2, 3, 4}`` to select a wash scheme for fixed tips, 66 | or drop tips when using DiTis. 67 | - ``"flush"`` blows out tips, but does not drop DiTis, and only does a short wash with fixed tips. 68 | - ``"reuse"`` continues pipetting without flushing, dropping or washing. 69 | Passing ``None`` is deprecated, results in ``"flush"`` behavior and emits a warning. 70 | 71 | partition_by : str 72 | one of 'auto' (default), 'source' or 'destination' 73 | 'auto': partitioning by source unless the source is a Trough 74 | 'source': partitioning by source columns 75 | 'destination': partitioning by destination columns 76 | kwargs 77 | Additional keyword arguments to pass to aspirate and dispense. 78 | Most prominent example: `liquid_class`. 79 | Take a look at `Worklist.aspirate_well` for the full list of options. 80 | """ 81 | # reformat the convenience parameters 82 | source_wells = np.array(source_wells).flatten("F") 83 | destination_wells = np.array(destination_wells).flatten("F") 84 | volumes = np.array(volumes).flatten("F") 85 | nmax = max((len(source_wells), len(destination_wells), len(volumes))) 86 | 87 | # Deal with deprecated behavior 88 | if wash_scheme is None: 89 | warnings.warn( 90 | "wash_scheme=None is deprecated. For flushing pass 'flush'.", DeprecationWarning, stacklevel=2 91 | ) 92 | wash_scheme = "flush" 93 | 94 | if len(source_wells) == 1: 95 | source_wells = np.repeat(source_wells, nmax) 96 | if len(destination_wells) == 1: 97 | destination_wells = np.repeat(destination_wells, nmax) 98 | if len(volumes) == 1: 99 | volumes = np.repeat(volumes, nmax) 100 | lengths = (len(source_wells), len(destination_wells), len(volumes)) 101 | if len(set(lengths)) != 1: 102 | raise ValueError(f"Number of source/destination/volumes must be equal. They were {lengths}") 103 | 104 | # automatic partitioning 105 | partition_by = optimize_partition_by(source, destination, partition_by, label) 106 | 107 | # the label applies to the entire transfer operation and is not logged at individual aspirate/dispense steps 108 | self.comment(label) 109 | nsteps = 0 110 | lvh_extra = 0 111 | 112 | for srcs, dsts, vols in partition_by_column(source_wells, destination_wells, volumes, partition_by): 113 | # make vector of volumes into vector of volume-lists 114 | vol_lists = [ 115 | partition_volume(float(v), max_volume=self.max_volume) if self.auto_split else [v] 116 | for v in vols 117 | ] 118 | # transfer from this source column until all wells are done 119 | npartitions = max(map(len, vol_lists)) 120 | # Count only the extra steps created by LVH 121 | lvh_extra += sum([len(vs) - 1 for vs in vol_lists]) 122 | for p in range(npartitions): 123 | naccessed = 0 124 | # iterate the rows 125 | for s, d, vs in zip(srcs, dsts, vol_lists): 126 | # transfer the next volume-fraction for this well 127 | if len(vs) > p: 128 | v = vs[p] 129 | if v > 0: 130 | self.aspirate(source, s, v, label=None, **kwargs) 131 | self.dispense( 132 | destination, 133 | d, 134 | v, 135 | label=None, 136 | compositions=[source.get_well_composition(s)], 137 | **kwargs, 138 | ) 139 | nsteps += 1 140 | if wash_scheme == "flush": 141 | self.flush() 142 | elif wash_scheme == "reuse": 143 | pass 144 | else: 145 | self.wash(scheme=wash_scheme) 146 | naccessed += 1 147 | # LVH: if multiple wells are accessed, don't group across partitions 148 | if npartitions > 1 and naccessed > 1 and not p == npartitions - 1: 149 | self.commit() 150 | # LVH: don't group across columns 151 | if npartitions > 1: 152 | self.commit() 153 | 154 | # Condense the labware logs into one operation 155 | # after the transfer operation completed to facilitate debugging. 156 | # Also include the number of extra steps because of LVH if applicable. 157 | if lvh_extra: 158 | if label: 159 | label = f"{label} ({lvh_extra} LVH steps)" 160 | else: 161 | label = f"{lvh_extra} LVH steps" 162 | if destination == source: 163 | source.condense_log(nsteps * 2, label=label) 164 | else: 165 | source.condense_log(nsteps, label=label) 166 | destination.condense_log(nsteps, label=label) 167 | return 168 | -------------------------------------------------------------------------------- /robotools/liquidhandling/__init__.py: -------------------------------------------------------------------------------- 1 | from robotools.liquidhandling.exceptions import ( 2 | VolumeOverflowError, 3 | VolumeUnderflowError, 4 | VolumeViolationException, 5 | ) 6 | from robotools.liquidhandling.labware import Labware, Trough 7 | -------------------------------------------------------------------------------- /robotools/liquidhandling/composition.py: -------------------------------------------------------------------------------- 1 | """Functions for tracking fluid composition through liquid handling operations.""" 2 | 3 | from typing import Dict, Mapping, Optional, Sequence, Union 4 | 5 | import numpy as np 6 | 7 | 8 | def combine_composition( 9 | volume_A: float, 10 | composition_A: Optional[Mapping[str, float]], 11 | volume_B: float, 12 | composition_B: Optional[Mapping[str, float]], 13 | ) -> Optional[Dict[str, float]]: 14 | """Computes the composition of a liquid, created by the mixing of two liquids (A and B). 15 | 16 | Parameters 17 | ---------- 18 | volume_A : float 19 | Volume of liquid A 20 | composition_A : dict 21 | Relative composition of liquid A 22 | volume_B : float 23 | Volume of liquid B 24 | composition_B : dict 25 | Relative composition of liquid B 26 | 27 | Returns 28 | ------- 29 | composition : dict 30 | Composition of the new liquid created by mixing the given volumes of A and B 31 | """ 32 | if composition_A is None or composition_B is None: 33 | return None 34 | # convert to volumetric fractions 35 | volumetric_fractions = {k: f * volume_A for k, f in composition_A.items()} 36 | # volumetrically add incoming fractions 37 | for k, f in composition_B.items(): 38 | if not k in volumetric_fractions: 39 | volumetric_fractions[k] = 0 40 | volumetric_fractions[k] += f * volume_B 41 | # convert back to relative fractions 42 | new_composition = {k: v / (volume_A + volume_B) for k, v in volumetric_fractions.items()} 43 | return new_composition 44 | 45 | 46 | def get_initial_composition( 47 | name: str, 48 | real_wells: np.ndarray, 49 | component_names: Mapping[str, Optional[str]], 50 | initial_volumes: np.ndarray, 51 | ) -> Dict[str, np.ndarray]: 52 | """Creates a dictionary of initial composition arrays. 53 | 54 | Parameters 55 | ---------- 56 | name : str 57 | Name of the labware - used for default component names. 58 | real_wells : array-like 59 | 2D array of non-virtual wells in the labware. 60 | component_names : dict 61 | User-provided dictionary that maps real well IDs to component names. 62 | initial_volumes : np.ndarray 63 | Initial volumes of real wells. 64 | 65 | Returns 66 | ------- 67 | composition : dict 68 | The component-wise dictionary of numpy arrays that describe the composition of real wells. 69 | """ 70 | possible_component_wells = set(np.unique(real_wells)) 71 | illegal_component_wells = set(component_names.keys()) - possible_component_wells 72 | if illegal_component_wells: 73 | raise ValueError(f"Invalid component name keys: {illegal_component_wells}") 74 | 75 | is_multiwell = len(real_wells) > 1 76 | composition: Dict[str, np.ndarray] = {} 77 | for idx, w in np.ndenumerate(real_wells): 78 | # Ignore None-valued component names, but don't allow naming of empty wells. 79 | if initial_volumes[idx] == 0: 80 | if component_names.get(w, None) is not None: 81 | raise ValueError( 82 | f"A component name '{component_names[w]}' was specified for {name}.{w}, but the corresponding initial volume is 0." 83 | ) 84 | continue 85 | 86 | # Fetch a name for identifying the liquid from this non-empty well 87 | cname = component_names.get(w, None) 88 | if cname is None: 89 | default_name = f"{name}.{w}" if is_multiwell else name 90 | cname = default_name 91 | 92 | # Make sure that a composition array exists 93 | if cname not in composition: 94 | composition[cname] = np.zeros_like(real_wells, dtype=float) 95 | 96 | # Mark this well as filled by this component 97 | composition[cname][idx] = 1 98 | return composition 99 | 100 | 101 | def get_trough_component_names( 102 | name: str, 103 | columns: int, 104 | column_names: Sequence[Optional[str]], 105 | initial_volumes: Sequence[Union[int, float]], 106 | ) -> Dict[str, Optional[str]]: 107 | """Determines a fully-specified component name dictionary for a trough. 108 | 109 | This helper function exists to provide a different default naming pattern for troughs. 110 | Instead of "stocks.A01" this function defaults to "stocks.column_01" with 1-based column numbering. 111 | 112 | Parameters 113 | ---------- 114 | name : str 115 | Name of the trough - used for default component names. 116 | columns : int 117 | Number of trough columns. 118 | column_names : array-like 119 | Column-wise component names. 120 | Must be given for all columns, but can contain None elements. 121 | initial_volumes : array-like 122 | Column-wise initial volumes. 123 | Used to determine if a default component name is needed. 124 | 125 | Returns 126 | ------- 127 | component_names : dict 128 | The component name dictionary that maps all row A well IDs to component names. 129 | """ 130 | if np.shape(column_names) != (columns,): 131 | raise ValueError(f"The column names {column_names} don't match the number of columns ({columns}).") 132 | if np.shape(initial_volumes) != (columns,): 133 | raise ValueError( 134 | f"The initial volumes {initial_volumes} don't match the number of columns ({columns})." 135 | ) 136 | 137 | if any([cname is not None and ivol == 0 for cname, ivol in zip(column_names, initial_volumes)]): 138 | raise ValueError( 139 | f"Empty columns must be unnamed." 140 | f"\n\tcolumn_names: {column_names}" 141 | f"\n\tinitial_volumes: {initial_volumes}" 142 | ) 143 | 144 | component_names = {} 145 | for c, (cname, ivol) in enumerate(zip(column_names, initial_volumes)): 146 | if ivol > 0 and cname is None: 147 | # Determine default name 148 | if columns > 1: 149 | cname = f"{name}.column_{c+1:02d}" 150 | else: 151 | cname = name 152 | component_names[f"A{c+1:02d}"] = cname 153 | return component_names 154 | -------------------------------------------------------------------------------- /robotools/liquidhandling/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions that indicate problems in liquid handling.""" 2 | 3 | from typing import Optional 4 | 5 | __all__ = ( 6 | "VolumeOverflowError", 7 | "VolumeUnderflowError", 8 | "VolumeViolationException", 9 | ) 10 | 11 | 12 | class VolumeViolationException(Exception): 13 | """Error indicating a violation of volume constraints.""" 14 | 15 | 16 | class VolumeOverflowError(VolumeViolationException): 17 | """Error that indicates the planned overflow of a well.""" 18 | 19 | def __init__( 20 | self, 21 | labware: str, 22 | well: str, 23 | current: float, 24 | change: float, 25 | threshold: float, 26 | label: Optional[str] = None, 27 | ) -> None: 28 | if label: 29 | super().__init__( 30 | f'Too much volume for "{labware}".{well}: {current} + {change} > {threshold} in step {label}' 31 | ) 32 | else: 33 | super().__init__(f'Too much volume for "{labware}".{well}: {current} + {change} > {threshold}') 34 | 35 | 36 | class VolumeUnderflowError(VolumeViolationException): 37 | """Error that indicates the planned underflow of a well.""" 38 | 39 | def __init__( 40 | self, 41 | labware: str, 42 | well: str, 43 | current: float, 44 | change: float, 45 | threshold: float, 46 | label: Optional[str] = None, 47 | ) -> None: 48 | if label: 49 | super().__init__( 50 | f'Too little volume in "{labware}".{well}: {current} - {change} < {threshold} in step {label}' 51 | ) 52 | else: 53 | super().__init__(f'Too little volume in "{labware}".{well}: {current} - {change} < {threshold}') 54 | -------------------------------------------------------------------------------- /robotools/liquidhandling/labware.py: -------------------------------------------------------------------------------- 1 | """Object-oriented, stateful labware representations.""" 2 | 3 | 4 | import warnings 5 | from typing import Dict, List, Mapping, Optional, Sequence, Tuple, Union 6 | 7 | import numpy as np 8 | 9 | from robotools.liquidhandling.composition import ( 10 | combine_composition, 11 | get_initial_composition, 12 | get_trough_component_names, 13 | ) 14 | from robotools.liquidhandling.exceptions import ( 15 | VolumeOverflowError, 16 | VolumeUnderflowError, 17 | ) 18 | 19 | 20 | class Labware: 21 | """Represents an array of liquid cavities.""" 22 | 23 | @property 24 | def history(self) -> List[Tuple[Optional[str], np.ndarray]]: 25 | """List of label/volumes history.""" 26 | return list(zip(self._labels, self._history)) 27 | 28 | @property 29 | def report(self) -> str: 30 | """A printable report of the labware history.""" 31 | report = self.name 32 | for label, state in self.history: 33 | if label: 34 | report += f"\n{label}" 35 | report += f"\n{np.round(state, decimals=1)}" 36 | report += "\n" 37 | return report 38 | 39 | @property 40 | def volumes(self) -> np.ndarray: 41 | """Current volumes in the labware.""" 42 | return self._volumes.copy() 43 | 44 | @property 45 | def wells(self) -> np.ndarray: 46 | """Array of well ids.""" 47 | return self._wells 48 | 49 | @property 50 | def indices(self) -> Dict[str, Tuple[int, int]]: 51 | """Mapping of well-ids to numpy indices.""" 52 | return self._indices 53 | 54 | @property 55 | def positions(self) -> Dict[str, int]: 56 | """Mapping of well-ids to EVOware-compatible position numbers.""" 57 | warnings.warn( 58 | "`Labware.positions` is deprecated in favor of model-specific implementations." 59 | " Use `robotools.evotools.get_well_positions()` or `robotools.fluenttools.get_well_positions()`.", 60 | DeprecationWarning, 61 | stacklevel=2, 62 | ) 63 | return self._positions 64 | 65 | @property 66 | def n_rows(self) -> int: 67 | return len(self.row_ids) 68 | 69 | @property 70 | def n_columns(self) -> int: 71 | return len(self.column_ids) 72 | 73 | @property 74 | def shape(self) -> Tuple[int, int]: 75 | """Number of rows and columns.""" 76 | return self.wells.shape 77 | 78 | @property 79 | def is_trough(self) -> bool: 80 | return self.virtual_rows != None 81 | 82 | @property 83 | def composition(self) -> Dict[str, np.ndarray]: 84 | """Relative composition of the liquids. 85 | 86 | This dictionary maps liquid names (keys) to arrays of relative amounts in each well. 87 | """ 88 | return self._composition 89 | 90 | def __init__( 91 | self, 92 | name: str, 93 | rows: int, 94 | columns: int, 95 | *, 96 | min_volume: float, 97 | max_volume: float, 98 | initial_volumes: Optional[Union[float, np.ndarray]] = None, 99 | virtual_rows: Optional[int] = None, 100 | component_names: Optional[Mapping[str, Optional[str]]] = None, 101 | ) -> None: 102 | """Creates a `Labware` object. 103 | 104 | Parameters 105 | ---------- 106 | name : str 107 | Label that the labware is identified by. 108 | rows : int 109 | Number of rows in the labware 110 | columns : int 111 | Number of columns in the labware 112 | min_volume : float 113 | Filling volume that must remain after an aspirate operation. 114 | max_volume : float 115 | Maximum volume that must not be exceeded after a dispense. 116 | initial_volumes : float, array-like, optional 117 | Initial filling volume of the wells (default: 0) 118 | virtual_rows : int, optional 119 | When specified to a positive number, the `Labware` is treated as a trough. 120 | Must be used in combination with `rows=1`. 121 | For example: A `Labware` with virtual rows can be accessed with 6 Tips, 122 | but has just one row in the `volumes` array. 123 | component_names : dict, optional 124 | A dictionary that names the content of non-empty real wells for composition tracking. 125 | """ 126 | # sanity checking 127 | if not isinstance(rows, int) or rows < 1: 128 | raise ValueError(f"Invalid rows: {rows}") 129 | if not isinstance(columns, int) or columns < 1: 130 | raise ValueError(f"Invalid columns: {columns}") 131 | if min_volume is None or min_volume < 0: 132 | raise ValueError(f"Invalid min_volume: {min_volume}") 133 | if max_volume is None or max_volume <= min_volume: 134 | raise ValueError(f"Invalid max_volume: {max_volume}") 135 | if virtual_rows is not None and rows != 1: 136 | raise ValueError("When using virtual_rows, the number of rows must be == 1") 137 | if virtual_rows is not None and virtual_rows < 1: 138 | raise ValueError(f"Invalid virtual_rows: {virtual_rows}") 139 | if virtual_rows and not isinstance(self, Trough): 140 | warnings.warn( 141 | "Troughs should be created with the robotools.Trough class.", 142 | UserWarning, 143 | stacklevel=2, 144 | ) 145 | 146 | # explode convenience parameters 147 | if initial_volumes is None: 148 | initial_volumes = 0 149 | initial_volumes = np.array(initial_volumes) 150 | if initial_volumes.shape == (): 151 | initial_volumes = np.full((rows, columns), initial_volumes) 152 | else: 153 | initial_volumes = initial_volumes.reshape((rows, columns)) 154 | assert initial_volumes.shape == ( 155 | rows, 156 | columns, 157 | ), f"Invalid shape of initial_volumes: {initial_volumes.shape}" 158 | if np.any(initial_volumes < 0): 159 | raise ValueError("initial_volume cannot be negative") 160 | if np.any(initial_volumes > max_volume): 161 | raise ValueError("initial_volume cannot be above max_volume") 162 | 163 | # initialize properties 164 | self.name = name 165 | self.row_ids = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZ"[: rows if not virtual_rows else virtual_rows]) 166 | self.column_ids = list(range(1, columns + 1)) 167 | self.min_volume = min_volume 168 | self.max_volume = max_volume 169 | self.virtual_rows = virtual_rows 170 | 171 | # generate arrays/mappings of well ids 172 | self._wells = np.array([[f"{row}{column:02d}" for column in self.column_ids] for row in self.row_ids]) 173 | if virtual_rows is None: 174 | self._indices = { 175 | f"{row}{column:02d}": (r, c) 176 | for r, row in enumerate(self.row_ids) 177 | for c, column in enumerate(self.column_ids) 178 | } 179 | self._positions = { 180 | f"{row}{column:02d}": 1 + c * rows + r 181 | for r, row in enumerate(self.row_ids) 182 | for c, column in enumerate(self.column_ids) 183 | } 184 | else: 185 | self._indices = { 186 | f"{vrow}{column:02d}": (0, c) 187 | for vr, vrow in enumerate(self.row_ids) 188 | for c, column in enumerate(self.column_ids) 189 | } 190 | self._positions = { 191 | f"{vrow}{column:02d}": 1 + c * virtual_rows + vr 192 | for vr, vrow in enumerate(self.row_ids) 193 | for c, column in enumerate(self.column_ids) 194 | } 195 | 196 | # initialize state variables 197 | self._volumes = initial_volumes.copy().astype(float) 198 | self._history: List[np.ndarray] = [self.volumes] 199 | self._labels: List[Optional[str]] = ["initial"] 200 | self._composition = get_initial_composition( 201 | name, 202 | real_wells=self.wells[[0], :] if virtual_rows else self.wells, 203 | component_names=component_names or {}, 204 | initial_volumes=initial_volumes, 205 | ) 206 | super().__init__() 207 | 208 | def get_well_composition(self, well: str) -> Dict[str, float]: 209 | """Retrieves the relative composition of a well. 210 | 211 | Parameters 212 | ---------- 213 | well : str 214 | ID of the well for which to retrieve the composition. 215 | 216 | Returns 217 | ------- 218 | composition : dict 219 | Keys: liquid names 220 | Values: relative amount 221 | """ 222 | if self._composition is None: 223 | return None 224 | idx = self.indices[well] 225 | well_comp = {k: f[idx] for k, f in self.composition.items() if f[idx] > 0} 226 | return well_comp 227 | 228 | def add( 229 | self, 230 | wells: Union[str, Sequence[str], np.ndarray], 231 | volumes: Union[float, Sequence[float], np.ndarray], 232 | label: Optional[str] = None, 233 | compositions: Optional[Sequence[Optional[Mapping[str, float]]]] = None, 234 | ) -> None: 235 | """Adds volumes to wells. 236 | 237 | Parameters 238 | ---------- 239 | wells : iterable of str 240 | Well ids 241 | volumes : float, iterable of float 242 | Scalar or iterable of volumes 243 | label : str 244 | Description of the operation 245 | compositions : iterable 246 | List of composition dictionaries ({ name : relative amount }) 247 | """ 248 | wells = np.array(wells).flatten("F") 249 | volumes = np.array(volumes).flatten("F") 250 | if len(volumes) == 1: 251 | volumes = np.repeat(volumes, len(wells)) 252 | assert len(volumes) == len(wells), "Number of volumes must equal the number of wells" 253 | assert np.all(volumes >= 0), "Volumes must be positive or zero." 254 | if compositions is not None: 255 | assert len(compositions) == len( 256 | wells 257 | ), "Well compositions must be given for either all or none of the wells." 258 | else: 259 | compositions = [None] * len(wells) 260 | 261 | for well, volume, composition in zip(wells, volumes, compositions): 262 | idx = self.indices[well] 263 | v_original = self._volumes[idx] 264 | v_new = v_original + volume 265 | 266 | if v_new > self.max_volume: 267 | raise VolumeOverflowError(self.name, well, v_original, volume, self.max_volume, label) 268 | 269 | self._volumes[idx] = v_new 270 | 271 | if composition is not None and self._composition is not None: 272 | assert isinstance(composition, dict), "Well compositions must be given as dicts" 273 | # update the volumentric composition for this well 274 | original_composition = self.get_well_composition(well) 275 | new_composition = combine_composition(v_original, original_composition, volume, composition) 276 | if new_composition is None: 277 | continue 278 | for k, f in new_composition.items(): 279 | if not k in self._composition: 280 | # a new liquid is being added 281 | self._composition[k] = np.zeros_like(self.volumes) 282 | self._composition[k][idx] = f 283 | 284 | self.log(label) 285 | return 286 | 287 | def remove( 288 | self, 289 | wells: Union[str, Sequence[str], np.ndarray], 290 | volumes: Union[float, Sequence[float], np.ndarray], 291 | label: Optional[str] = None, 292 | ) -> None: 293 | """Removes volumes from wells. 294 | 295 | Parameters 296 | ---------- 297 | wells : iterable of str 298 | Well ids 299 | volumes : float, iterable of float 300 | Scalar or iterable of volumes 301 | label : str 302 | Description of the operation 303 | """ 304 | wells = np.array(wells).flatten("F") 305 | volumes = np.array(volumes).flatten("F") 306 | if len(volumes) == 1: 307 | volumes = np.repeat(volumes, len(wells)) 308 | assert len(volumes) == len(wells), "Number of volumes must number of wells" 309 | assert np.all(volumes >= 0), "Volumes must be positive or zero." 310 | for well, volume in zip(wells, volumes): 311 | idx = self.indices[well] 312 | v_original = self._volumes[idx] 313 | v_new = v_original - volume 314 | 315 | if v_new < self.min_volume: 316 | raise VolumeUnderflowError(self.name, well, v_original, volume, self.min_volume, label) 317 | 318 | self._volumes[idx] -= volume 319 | self.log(label) 320 | return 321 | 322 | def log(self, label: Optional[str]) -> None: 323 | """Logs the current volumes to the history. 324 | 325 | Parameters 326 | ---------- 327 | label : str 328 | A label to insert in the history. 329 | """ 330 | self._history.append(self.volumes) 331 | self._labels.append(label) 332 | return 333 | 334 | def condense_log(self, n: int, label: Optional[str] = "last") -> None: 335 | """Condense the last n log entries. 336 | 337 | Parameters 338 | ---------- 339 | n : int 340 | Number of log entries to condense 341 | label : str 342 | 'first', 'last' or label of the condensed entry (default: label of the last entry in the condensate) 343 | """ 344 | if label == "first": 345 | label = self._labels[len(self._labels) - n] 346 | if label == "last": 347 | label = self._labels[-1] 348 | state = self._history[-1] 349 | # cut away the history 350 | self._labels = self._labels[:-n] 351 | self._history = self._history[:-n] 352 | # append the last state 353 | self._labels.append(label) 354 | self._history.append(state) 355 | return 356 | 357 | def __repr__(self) -> str: 358 | return f"{self.name}\n{np.round(self.volumes, decimals=1)}" 359 | 360 | def __str__(self) -> str: 361 | return self.__repr__() 362 | 363 | 364 | class Trough(Labware): 365 | """Special type of labware that can be accessed by many pipette tips in parallel.""" 366 | 367 | def __init__( 368 | self, 369 | name: str, 370 | virtual_rows: int, 371 | columns: int, 372 | *, 373 | min_volume: float, 374 | max_volume: float, 375 | initial_volumes: Union[float, Sequence[float], np.ndarray] = 0, 376 | column_names: Optional[Sequence[Optional[str]]] = None, 377 | ) -> None: 378 | """Creates a `Labware` object. 379 | 380 | Parameters 381 | ---------- 382 | name : str 383 | Label that the labware is identified by. 384 | virtual_rows : int, optional 385 | Number of tips that may access the trough in parallel. 386 | For example: A `Labware` with virtual rows can be accessed with 6 Tips, 387 | but has just one row in the `volumes` array. 388 | columns : int 389 | Number of columns in the labware 390 | min_volume : float 391 | Filling volume that must remain after an aspirate operation. 392 | max_volume : float 393 | Maximum volume that must not be exceeded after a dispense. 394 | initial_volumes : float, array-like, optional 395 | Initial filling volume of the wells (default: 0) 396 | column_names : array-like, optional 397 | A list/tuple of names for the column-wise contents of the troughs. 398 | If provided, these names are used for composition tracking. 399 | """ 400 | # Convert lazily scalar-valued parameters to lists 401 | if column_names is None: 402 | column_names = [None] * columns 403 | if isinstance(column_names, str): 404 | column_names = [column_names] 405 | 406 | if isinstance(initial_volumes, (int, float)): 407 | initial_volumes = [initial_volumes] * columns 408 | 409 | # Determine component names with a different default pattern compared to Labware 410 | component_names = get_trough_component_names(name, columns, column_names, initial_volumes) 411 | 412 | super().__init__( 413 | name=name, 414 | rows=1, 415 | columns=columns, 416 | min_volume=min_volume, 417 | max_volume=max_volume, 418 | initial_volumes=initial_volumes, 419 | virtual_rows=virtual_rows, 420 | component_names=component_names, 421 | ) 422 | -------------------------------------------------------------------------------- /robotools/liquidhandling/test_composition.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from robotools.evotools.worklist import EvoWorklist 5 | from robotools.liquidhandling.composition import ( 6 | combine_composition, 7 | get_initial_composition, 8 | get_trough_component_names, 9 | ) 10 | from robotools.liquidhandling.labware import Labware, Trough 11 | 12 | 13 | class TestCompositionTracking: 14 | def test_get_initial_composition(self) -> None: 15 | wells2x3 = np.array( 16 | [ 17 | ["A01", "A02", "A03"], 18 | ["B01", "B02", "B03"], 19 | ] 20 | ) 21 | 22 | # Raise errors on invalid component well ids 23 | with pytest.raises(ValueError, match=r"Invalid component name keys: \{'G02'\}"): 24 | get_initial_composition("eppis", wells2x3, dict(G02="beer"), np.zeros((2, 3))) 25 | 26 | # Raise errors on attempts to name empty wells 27 | with pytest.raises(ValueError, match=r"name 'beer' was specified for eppis.A02, but"): 28 | get_initial_composition("eppis", wells2x3, dict(A02="beer"), np.zeros((2, 3))) 29 | 30 | # No components if all wells are empty 31 | result = get_initial_composition("eppis", wells2x3, {}, np.zeros((2, 3))) 32 | assert result == {} 33 | 34 | # Default to labware name for one-well labwares 35 | result = get_initial_composition("media", [["A01"]], {}, np.atleast_2d(100)) 36 | assert "media" in result 37 | assert len(result) == 1 38 | 39 | # Assigning default component names to all wells 40 | result = get_initial_composition("samples", wells2x3, {}, np.ones((2, 3))) 41 | assert isinstance(result, dict) 42 | # Non-empty wells default to unique component names 43 | assert "samples.A01" in result 44 | assert "samples.B03" in result 45 | # Only the well with the component has 100 % of it 46 | np.testing.assert_array_equal( 47 | result["samples.B02"], 48 | np.array( 49 | [ 50 | [0, 0, 0], 51 | [0, 1, 0], 52 | ] 53 | ), 54 | ) 55 | 56 | # Mix of user-defined and default component names 57 | result = get_initial_composition("samples", wells2x3, {"B01": "water"}, np.ones((2, 3))) 58 | assert isinstance(result, dict) 59 | # Non-empty wells default to unique component names 60 | assert "samples.A01" in result 61 | assert "water" in result 62 | assert "samples.B03" in result 63 | return 64 | 65 | def test_get_trough_component_names(self) -> None: 66 | # The function requies the correct number of column names and initial volumes 67 | # and should raise informative errors otherwise. 68 | with pytest.raises(ValueError, match=r"column names \['A', 'B', 'C'\] don't match"): 69 | get_trough_component_names("water", 2, ["A", "B", "C"], [20, 10]) 70 | with pytest.raises(ValueError, match=r"initial volumes \[20, 10\] don't match"): 71 | get_trough_component_names("water", 3, ["A", "B", "C"], [20, 10]) 72 | with pytest.raises(ValueError, match=r"initial volumes \[\[20], \[10\]\] don't match"): 73 | get_trough_component_names("water", 2, ["A", "B"], [[20], [10]]) 74 | 75 | # It should also check that no names are given for empty columns 76 | with pytest.raises(ValueError, match="Empty columns must be unnamed"): 77 | get_trough_component_names("water", 2, ["A", "B"], [20, 0]) 78 | 79 | # It explicitly sets names of empty wells to None 80 | result = get_trough_component_names("water", 1, [None], [0]) 81 | assert result == {"A01": None} 82 | 83 | # And defaults to the labware name of single-column troughs 84 | result = get_trough_component_names("water", 1, [None], [100]) 85 | assert result == {"A01": "water"} 86 | 87 | # But includes the 1-based column number in the default name for non-empty wells 88 | result = get_trough_component_names("stocks", 2, [None, None], [0, 50]) 89 | assert result == {"A01": None, "A02": "stocks.column_02"} 90 | 91 | # User-provided names, default naming and empty-well all in one: 92 | result = get_trough_component_names("stocks", 4, ["acid", "base", None, None], [100, 100, 50, 0]) 93 | assert result == {"A01": "acid", "A02": "base", "A03": "stocks.column_03", "A04": None} 94 | return 95 | 96 | def test_combine_composition(self) -> None: 97 | A = dict(water=1) 98 | B = dict(water=0.5, glucose=0.5) 99 | expected = {"water": (1 * 10 + 0.5 * 15) / (10 + 15), "glucose": 0.5 * 15 / (10 + 15)} 100 | actual = combine_composition(10, A, 15, B) 101 | assert actual == expected 102 | return 103 | 104 | def test_combine_unknown_composition(self) -> None: 105 | A = dict(water=1) 106 | B = None 107 | expected = None 108 | actual = combine_composition(10, A, 15, B) 109 | assert actual == expected 110 | return 111 | 112 | def test_labware_init(self) -> None: 113 | minmax = dict(min_volume=0, max_volume=4000) 114 | # without initial volume, there's no composition tracking 115 | A = Labware("glc", 6, 8, **minmax) 116 | assert isinstance(A.composition, dict) 117 | assert len(A.composition) == 0 118 | assert A.get_well_composition("A01") == {} 119 | 120 | # Single-well Labware defaults to the labware name for components 121 | A = Labware("x", 1, 1, **minmax, initial_volumes=300) 122 | assert set(A.composition) == {"x"} 123 | with pytest.warns(UserWarning, match="Trough class"): 124 | A = Labware("x", 1, 1, virtual_rows=3, **minmax, initial_volumes=300) 125 | assert set(A.composition) == {"x"} 126 | 127 | # by setting an initial volume, the well-wise liquids take part in composition tracking 128 | A = Labware("glc", 6, 8, **minmax, initial_volumes=100) 129 | assert isinstance(A.composition, dict) 130 | assert len(A.composition) == 48 131 | assert "glc.A01" in A.composition 132 | assert "glc.F08" in A.composition 133 | assert A.get_well_composition("A01") == {"glc.A01": 1} 134 | 135 | # Only wells with initial volumes take part 136 | A = Labware("test", 1, 3, **minmax, initial_volumes=[10, 0, 0], component_names=dict(A01="water")) 137 | assert set(A.composition) == {"water"} 138 | return 139 | 140 | def test_get_well_composition(self) -> None: 141 | A = Labware("glc", 6, 8, min_volume=0, max_volume=4000) 142 | A._composition = { 143 | "glc": 0.25 * np.ones_like(A.volumes), 144 | "water": 0.75 * np.ones_like(A.volumes), 145 | } 146 | expected = { 147 | "glc": 0.25, 148 | "water": 0.75, 149 | } 150 | assert A.get_well_composition("A01") == expected 151 | return 152 | 153 | def test_labware_add(self) -> None: 154 | A = Labware( 155 | "water", 156 | 6, 157 | 8, 158 | min_volume=0, 159 | max_volume=4000, 160 | initial_volumes=10, 161 | component_names={ 162 | "A01": "water", 163 | "B01": "water", 164 | }, 165 | ) 166 | water_comp = np.array( 167 | [ 168 | [1, 0, 0, 0, 0, 0, 0, 0], 169 | [1, 0, 0, 0, 0, 0, 0, 0], 170 | [0, 0, 0, 0, 0, 0, 0, 0], 171 | [0, 0, 0, 0, 0, 0, 0, 0], 172 | [0, 0, 0, 0, 0, 0, 0, 0], 173 | [0, 0, 0, 0, 0, 0, 0, 0], 174 | ] 175 | ) 176 | np.testing.assert_array_equal(A.composition["water"], water_comp) 177 | 178 | A.add( 179 | wells=["A01", "B01"], 180 | volumes=[10, 20], 181 | compositions=[ 182 | dict(glc=0.5, water=0.5), 183 | dict(glc=1), 184 | ], 185 | ) 186 | 187 | assert "water" in A.composition 188 | assert "glc" in A.composition 189 | assert A.get_well_composition("A01") == dict(water=0.75, glc=0.25) 190 | assert A.get_well_composition("B01") == dict(water=1 / 3, glc=2 / 3) 191 | return 192 | 193 | def test_dilution_series(self) -> None: 194 | A = Labware("dilutions", 1, 3, min_volume=0, max_volume=100) 195 | # 100 % in 1st column 196 | A.add(wells="A01", volumes=100, compositions=[dict(glucose=1)]) 197 | # 10x dilution to 2nd 198 | A.add(wells="A02", volumes=10, compositions=[A.get_well_composition("A01")]) 199 | A.add(wells="A02", volumes=90, compositions=[dict(water=1)]) 200 | # 4x dilution to 3rd 201 | A.add(wells="A03", volumes=2.5, compositions=[A.get_well_composition("A02")]) 202 | A.add(wells="A03", volumes=7.5, compositions=[dict(water=1)]) 203 | 204 | np.testing.assert_array_equal(A.volumes, [[100, 100, 10]]) 205 | np.testing.assert_array_equal(A.composition["water"], [[0, 0.9, 0.975]]) 206 | np.testing.assert_array_equal(A.composition["glucose"], [[1, 0.1, 0.025]]) 207 | assert A.get_well_composition("A01") == dict(glucose=1) 208 | assert A.get_well_composition("A02") == dict(glucose=0.1, water=0.9) 209 | assert A.get_well_composition("A03") == dict(glucose=0.025, water=0.975) 210 | return 211 | 212 | def test_trough_init(self) -> None: 213 | minmax = dict(min_volume=0, max_volume=100_000) 214 | # Single-column troughs use the labware name for the composition 215 | W = Trough("water", 2, 1, **minmax, initial_volumes=10000) 216 | assert set(W.composition) == {"water"} 217 | 218 | # Multi-column troughs get component names automatically 219 | A = Trough(name="water", virtual_rows=2, columns=3, **minmax, initial_volumes=[0, 200, 200]) 220 | assert set(A.composition) == {"water.column_02", "water.column_03"} 221 | assert "water.column_01" not in A.composition 222 | assert "water.column_02" in A.composition 223 | assert "water.column_03" in A.composition 224 | 225 | # Components in troughs are named via a list, because the dict would 226 | # require well names and columns could lead to 0/1-based confusion. 227 | T = Trough( 228 | "stocks", 229 | 6, 230 | 3, 231 | min_volume=0, 232 | max_volume=10_000, 233 | initial_volumes=[100, 200, 0], 234 | column_names=["rich", "complex", None], 235 | ) 236 | assert set(T.composition.keys()) == {"rich", "complex"} 237 | 238 | # Naming just some of them works too 239 | A = Trough( 240 | name="alice", 241 | virtual_rows=2, 242 | columns=3, 243 | **minmax, 244 | initial_volumes=[0, 200, 200], 245 | column_names=[None, "NaCl", None], 246 | ) 247 | assert set(A.composition) == {"NaCl", "alice.column_03"} 248 | np.testing.assert_array_equal(A.composition["NaCl"], [[0, 1, 0]]) 249 | 250 | def test_trough_composition(self) -> None: 251 | T = Trough("media", 8, 1, min_volume=1000, max_volume=25000) 252 | T.add(wells=T.wells, volumes=100, compositions=[dict(glucose=1)] * 8) 253 | T.add(wells=T.wells, volumes=900, compositions=[dict(water=1)] * 8) 254 | np.testing.assert_array_equal(T.composition["water"], [[0.9]]) 255 | np.testing.assert_array_equal(T.composition["glucose"], [[0.1]]) 256 | assert T.get_well_composition("B01") == dict(water=0.9, glucose=0.1) 257 | return 258 | 259 | def test_worklist_dilution(self) -> None: 260 | W = Trough("water", 4, 1, min_volume=0, max_volume=10000, initial_volumes=10000) 261 | G = Trough("glucose", 4, 1, min_volume=0, max_volume=10000, initial_volumes=10000) 262 | D = Labware("dilutions", 4, 2, min_volume=0, max_volume=10000) 263 | 264 | with EvoWorklist() as wl: 265 | # 100 % in first column 266 | wl.transfer(G, G.wells, D, D.wells[:, 0], volumes=[1000, 800, 600, 550]) 267 | wl.transfer(W, W.wells, D, D.wells[:, 0], volumes=[0, 200, 400, 450]) 268 | np.testing.assert_array_equal(D.volumes[:, 0], [1000, 1000, 1000, 1000]) 269 | np.testing.assert_array_equal(D.composition["glucose"][:, 0], [1, 0.8, 0.6, 0.55]) 270 | np.testing.assert_array_equal(D.composition["water"][:, 0], [0, 0.2, 0.4, 0.45]) 271 | # 10x dilution to the 2nd column 272 | wl.transfer(D, D.wells[:, 0], D, D.wells[:, 1], volumes=100) 273 | wl.transfer(W, W.wells, D, D.wells[:, 1], volumes=900) 274 | np.testing.assert_array_equal(D.volumes[:, 1], [1000, 1000, 1000, 1000]) 275 | np.testing.assert_allclose(D.composition["glucose"][:, 1], [0.1, 0.08, 0.06, 0.055]) 276 | np.testing.assert_allclose(D.composition["water"][:, 1], [0.9, 0.92, 0.94, 0.945]) 277 | 278 | return 279 | 280 | def test_worklist_distribution(self) -> None: 281 | W = Trough("water", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000) 282 | G = Trough("glucose", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000) 283 | D = Labware("dilutions", 2, 4, min_volume=0, max_volume=10000) 284 | 285 | with EvoWorklist() as wl: 286 | # transfer some glucose 287 | wl.transfer( 288 | G, 289 | G.wells[:, [0] * 4], 290 | D, 291 | D.wells, 292 | volumes=np.array( 293 | [ 294 | [100, 80, 60, 55], 295 | [55, 60, 80, 100], 296 | ] 297 | ), 298 | ) 299 | # fill up to 100 300 | wl.transfer(W, W.wells[:, [0] * 4], D, D.wells, volumes=100 - D.volumes) 301 | np.testing.assert_allclose(D.volumes, 100) 302 | np.testing.assert_array_equal(D.composition["glucose"][0, :], [1, 0.8, 0.6, 0.55]) 303 | np.testing.assert_array_equal(D.composition["glucose"][1, :], [0.55, 0.6, 0.8, 1]) 304 | np.testing.assert_array_equal(D.composition["water"][0, :], [0, 0.2, 0.4, 0.45]) 305 | np.testing.assert_array_equal(D.composition["water"][1, :], [0.45, 0.4, 0.2, 0]) 306 | # dilute 2x with water 307 | wl.distribute(source=W, source_column=0, destination=D, destination_wells=D.wells, volume=100) 308 | np.testing.assert_allclose(D.volumes, 200) 309 | np.testing.assert_array_equal(D.composition["glucose"][0, :], [0.5, 0.4, 0.3, 0.275]) 310 | np.testing.assert_array_equal(D.composition["glucose"][1, :], [0.275, 0.3, 0.4, 0.5]) 311 | np.testing.assert_array_equal(D.composition["water"][0, :], [0.5, 0.6, 0.7, 0.725]) 312 | np.testing.assert_array_equal(D.composition["water"][1, :], [0.725, 0.7, 0.6, 0.5]) 313 | 314 | return 315 | 316 | def test_worklist_mix_no_composition_change(self) -> None: 317 | A = Labware("solution", 2, 3, min_volume=0, max_volume=1000) 318 | A._composition["water"] = 0.25 * np.ones_like(A.volumes) 319 | A._composition["salt"] = 0.75 * np.ones_like(A.volumes) 320 | A._volumes = np.ones_like(A.volumes) * 500 321 | with EvoWorklist() as wl: 322 | wl.transfer(A, A.wells, A, A.wells, volumes=300) 323 | # make sure that the composition of the liquid is not changed 324 | np.testing.assert_array_equal(A.composition["water"], 0.25 * np.ones_like(A.volumes)) 325 | np.testing.assert_array_equal(A.composition["salt"], 0.75 * np.ones_like(A.volumes)) 326 | return 327 | -------------------------------------------------------------------------------- /robotools/liquidhandling/test_labware.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import numpy as np 4 | import pytest 5 | 6 | from robotools.liquidhandling.exceptions import ( 7 | VolumeOverflowError, 8 | VolumeUnderflowError, 9 | ) 10 | from robotools.liquidhandling.labware import Labware, Trough 11 | 12 | 13 | class TestStandardLabware: 14 | def test_init(self) -> None: 15 | plate = Labware("TestPlate", 2, 3, min_volume=50, max_volume=250, initial_volumes=30) 16 | assert plate.name == "TestPlate" 17 | assert plate.is_trough == False 18 | assert plate.row_ids == tuple("AB") 19 | assert plate.column_ids == [1, 2, 3] 20 | assert plate.n_rows == 2 21 | assert plate.n_columns == 3 22 | assert plate.min_volume == 50 23 | assert plate.max_volume == 250 24 | assert len(plate.history) == 1 25 | np.testing.assert_array_equal(plate.volumes, np.array([[30, 30, 30], [30, 30, 30]])) 26 | exp = { 27 | "A01": (0, 0), 28 | "A02": (0, 1), 29 | "A03": (0, 2), 30 | "B01": (1, 0), 31 | "B02": (1, 1), 32 | "B03": (1, 2), 33 | } 34 | assert plate.indices == exp 35 | with pytest.warns(DeprecationWarning, match="in favor of model-specific"): 36 | assert plate.positions == { 37 | "A01": 1, 38 | "A02": 3, 39 | "A03": 5, 40 | "B01": 2, 41 | "B02": 4, 42 | "B03": 6, 43 | } 44 | return 45 | 46 | def test_invalid_init(self) -> None: 47 | with pytest.raises(ValueError): 48 | Labware("A", 0, 3, min_volume=10, max_volume=250) 49 | with pytest.raises(ValueError): 50 | Labware("A", 3, 0, min_volume=10, max_volume=250) 51 | with pytest.raises(ValueError): 52 | Labware("A", 3, 4, min_volume=10, max_volume=250, virtual_rows=2) 53 | with pytest.raises(ValueError): 54 | Labware("A", 1, 4, min_volume=10, max_volume=250, virtual_rows=0) 55 | return 56 | 57 | def test_volume_limits(self) -> None: 58 | with pytest.raises(ValueError): 59 | Labware("A", 3, 4, min_volume=-30, max_volume=100) 60 | with pytest.raises(ValueError): 61 | Labware("A", 3, 4, min_volume=100, max_volume=70) 62 | with pytest.raises(ValueError): 63 | Labware("A", 3, 4, min_volume=10, max_volume=70, initial_volumes=100) 64 | with pytest.raises(ValueError): 65 | Labware("A", 3, 4, min_volume=10, max_volume=70, initial_volumes=-10) 66 | Labware("A", 3, 4, min_volume=10, max_volume=70, initial_volumes=50) 67 | return 68 | 69 | def test_initial_volumes(self) -> None: 70 | plate = Labware("TestPlate", 1, 3, min_volume=50, max_volume=250, initial_volumes=[20, 30, 40]) 71 | np.testing.assert_array_equal( 72 | plate.volumes, 73 | np.array( 74 | [ 75 | [20, 30, 40], 76 | ] 77 | ), 78 | ) 79 | return 80 | 81 | def test_logging(self) -> None: 82 | plate = Labware("TestPlate", 2, 3, min_volume=50, max_volume=250) 83 | plate.add(plate.wells, 25) 84 | plate.add(plate.wells, 25) 85 | plate.add(plate.wells, 25) 86 | plate.add(plate.wells, 25) 87 | assert len(plate.history) == 5 88 | return 89 | 90 | def test_log_condensation_first(self) -> None: 91 | plate = Labware("TestPlate", 2, 3, min_volume=50, max_volume=250) 92 | plate.add(plate.wells, 25, label="A") 93 | plate.add(plate.wells, 25, label="B") 94 | plate.add(plate.wells, 25, label="C") 95 | plate.add(plate.wells, 25, label="D") 96 | assert len(plate.history) == 5 97 | 98 | # condense the last two as 'D' 99 | plate.condense_log(2, label="last") 100 | assert len(plate.history) == 4 101 | assert plate.history[-1][0] == "D" 102 | np.testing.assert_array_equal( 103 | plate.history[-1][1], 104 | np.array( 105 | [ 106 | [100, 100, 100], 107 | [100, 100, 100], 108 | ] 109 | ), 110 | ) 111 | 112 | # condense the last three as 'A' 113 | plate.condense_log(3, label="first") 114 | assert len(plate.history) == 2 115 | assert plate.history[-1][0] == "A" 116 | np.testing.assert_array_equal( 117 | plate.history[-1][1], 118 | np.array( 119 | [ 120 | [100, 100, 100], 121 | [100, 100, 100], 122 | ] 123 | ), 124 | ) 125 | 126 | # condense the remaining two as 'prepared' 127 | plate.condense_log(3, label="prepared") 128 | assert len(plate.history) == 1 129 | assert plate.history[-1][0] == "prepared" 130 | np.testing.assert_array_equal( 131 | plate.history[-1][1], 132 | np.array( 133 | [ 134 | [100, 100, 100], 135 | [100, 100, 100], 136 | ] 137 | ), 138 | ) 139 | return 140 | 141 | def test_add_valid(self) -> None: 142 | plate = Labware("TestPlate", 4, 6, min_volume=100, max_volume=250) 143 | wells = ["A01", "A02", "B04"] 144 | plate.add(wells, 150) 145 | plate.add(wells, 3.5) 146 | assert len(plate.history) == 3 147 | for well in wells: 148 | assert plate.volumes[plate.indices[well]] == 153.5 149 | return 150 | 151 | def test_add_too_much(self) -> None: 152 | plate = Labware("TestPlate", 4, 6, min_volume=100, max_volume=250) 153 | wells = ["A01", "A02", "B04"] 154 | with pytest.raises(VolumeOverflowError): 155 | plate.add(wells, 500) 156 | return 157 | 158 | def test_remove_valid(self) -> None: 159 | plate = Labware("TestPlate", 2, 3, min_volume=50, max_volume=250, initial_volumes=200) 160 | wells = ["A01", "A02", "B03"] 161 | plate.remove(wells, 50) 162 | assert len(plate.history) == 2 163 | np.testing.assert_array_equal(plate.volumes, np.array([[150, 150, 200], [200, 200, 150]])) 164 | return 165 | 166 | def test_remove_too_much(self) -> None: 167 | plate = Labware("TestPlate", 4, 6, min_volume=100, max_volume=250) 168 | wells = ["A01", "A02", "B04"] 169 | with pytest.raises(VolumeUnderflowError): 170 | plate.remove(wells, 500) 171 | assert len(plate.history) == 1 172 | return 173 | 174 | 175 | class TestTroughLabware: 176 | def test_warns_on_api(self) -> None: 177 | with pytest.warns(UserWarning, match="Troughs should be created with"): 178 | Labware("test", rows=1, columns=2, min_volume=100, max_volume=3000, virtual_rows=4) 179 | 180 | with warnings.catch_warnings(): 181 | warnings.simplefilter("error") 182 | Trough("test", virtual_rows=6, columns=2, min_volume=100, max_volume=3000) 183 | return 184 | 185 | def test_init_trough(self) -> None: 186 | trough = Trough("TestTrough", 5, 4, min_volume=1000, max_volume=50 * 1000, initial_volumes=30 * 1000) 187 | assert trough.name == "TestTrough" 188 | assert trough.is_trough 189 | assert trough.row_ids == tuple("ABCDE") 190 | assert trough.column_ids == [1, 2, 3, 4] 191 | assert trough.min_volume == 1000 192 | assert trough.max_volume == 50 * 1000 193 | assert len(trough.history) == 1 194 | np.testing.assert_array_equal( 195 | trough.volumes, np.array([[30 * 1000, 30 * 1000, 30 * 1000, 30 * 1000]]) 196 | ) 197 | assert trough.indices == { 198 | "A01": (0, 0), 199 | "A02": (0, 1), 200 | "A03": (0, 2), 201 | "A04": (0, 3), 202 | "B01": (0, 0), 203 | "B02": (0, 1), 204 | "B03": (0, 2), 205 | "B04": (0, 3), 206 | "C01": (0, 0), 207 | "C02": (0, 1), 208 | "C03": (0, 2), 209 | "C04": (0, 3), 210 | "D01": (0, 0), 211 | "D02": (0, 1), 212 | "D03": (0, 2), 213 | "D04": (0, 3), 214 | "E01": (0, 0), 215 | "E02": (0, 1), 216 | "E03": (0, 2), 217 | "E04": (0, 3), 218 | } 219 | with pytest.warns(DeprecationWarning, match="in favor of model-specific"): 220 | assert trough.positions == { 221 | "A01": 1, 222 | "A02": 6, 223 | "A03": 11, 224 | "A04": 16, 225 | "B01": 2, 226 | "B02": 7, 227 | "B03": 12, 228 | "B04": 17, 229 | "C01": 3, 230 | "C02": 8, 231 | "C03": 13, 232 | "C04": 18, 233 | "D01": 4, 234 | "D02": 9, 235 | "D03": 14, 236 | "D04": 19, 237 | "E01": 5, 238 | "E02": 10, 239 | "E03": 15, 240 | "E04": 20, 241 | } 242 | return 243 | 244 | def test_initial_volumes(self) -> None: 245 | trough = Trough( 246 | "TestTrough", 247 | 5, 248 | 4, 249 | min_volume=1000, 250 | max_volume=50 * 1000, 251 | initial_volumes=[30 * 1000, 20 * 1000, 20 * 1000, 20 * 1000], 252 | ) 253 | np.testing.assert_array_equal( 254 | trough.volumes, 255 | np.array( 256 | [ 257 | [30 * 1000, 20 * 1000, 20 * 1000, 20 * 1000], 258 | ] 259 | ), 260 | ) 261 | return 262 | 263 | def test_trough_add_valid(self) -> None: 264 | trough = Trough("TestTrough", 3, 4, min_volume=100, max_volume=250) 265 | # adding into the first column (which is actually one well) 266 | trough.add(["A01", "B01"], 50) 267 | np.testing.assert_array_equal(trough.volumes, np.array([[100, 0, 0, 0]])) 268 | # adding to the last row (separate wells) 269 | trough.add(["C01", "C02", "C03"], 50) 270 | np.testing.assert_array_equal(trough.volumes, np.array([[150, 50, 50, 0]])) 271 | assert len(trough.history) == 3 272 | return 273 | 274 | def test_trough_add_too_much(self) -> None: 275 | trough = Trough("TestTrough", 3, 4, min_volume=100, max_volume=1000) 276 | # adding into the first column (which is actually one well) 277 | with pytest.raises(VolumeOverflowError): 278 | trough.add(["A01", "B01"], 600) 279 | return 280 | 281 | def test_trough_remove_valid(self) -> None: 282 | trough = Trough("TestTrough", 3, 4, min_volume=1000, max_volume=30000, initial_volumes=3000) 283 | # adding into the first column (which is actually one well) 284 | trough.remove(["A01", "B01"], 50) 285 | np.testing.assert_array_equal(trough.volumes, np.array([[2900, 3000, 3000, 3000]])) 286 | # adding to the last row (separate wells) 287 | trough.remove(["C01", "C02", "C03"], 50) 288 | np.testing.assert_array_equal(trough.volumes, np.array([[2850, 2950, 2950, 3000]])) 289 | assert len(trough.history) == 3 290 | return 291 | 292 | def test_trough_remove_too_much(self) -> None: 293 | trough = Trough("TestTrough", 3, 4, min_volume=1000, max_volume=30 * 1000, initial_volumes=3000) 294 | # adding into the first column (which is actually one well) 295 | with pytest.raises(VolumeUnderflowError): 296 | trough.remove(["A01", "B01"], 2000) 297 | return 298 | -------------------------------------------------------------------------------- /robotools/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuBiotech/robotools/7978d80417b52ffa74a0d144c2063eb109555475/robotools/py.typed -------------------------------------------------------------------------------- /robotools/test_transform.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from robotools.transform import WellRandomizer, WellRotator, WellShifter 5 | 6 | 7 | class TestWellShifter: 8 | def test_identity_transform(self) -> None: 9 | A = (6, 8) 10 | B = (8, 12) 11 | shifter = WellShifter(A, B, shifted_A01="A01") 12 | 13 | original = ["A01", "C03", "D06", "F08"] 14 | expected = ["A01", "C03", "D06", "F08"] 15 | shifted = shifter.shift(original) 16 | np.testing.assert_array_equal(expected, shifter.shift(original)) 17 | np.testing.assert_array_equal(shifter.unshift(shifted), original) 18 | return 19 | 20 | def test_center_shift(self) -> None: 21 | A = (6, 8) 22 | B = (8, 12) 23 | shifter = WellShifter(A, B, shifted_A01="B03") 24 | 25 | original = ["A01", "C03", "D06", "F08"] 26 | expected = ["B03", "D05", "E08", "G10"] 27 | shifted = shifter.shift(original) 28 | np.testing.assert_array_equal(expected, shifter.shift(original)) 29 | np.testing.assert_array_equal(shifter.unshift(shifted), original) 30 | return 31 | 32 | def test_boundcheck(self) -> None: 33 | A = (6, 8) 34 | B = (8, 12) 35 | 36 | with pytest.raises(ValueError): 37 | WellShifter(A, B, shifted_A01="E03") 38 | 39 | with pytest.raises(ValueError): 40 | WellShifter(A, B, shifted_A01="B06") 41 | return 42 | 43 | 44 | class TestWellRotator: 45 | def test_init(self) -> None: 46 | rotator = WellRotator(original_shape=(7, 3)) 47 | assert rotator.original_shape == (7, 3) 48 | assert rotator.rotated_shape == (3, 7) 49 | return 50 | 51 | def test_clockwise(self) -> None: 52 | A = (6, 8) 53 | rotator = WellRotator(A) 54 | 55 | original = ["A01", "C03", "D06", "F08", "B04"] 56 | expected = ["A06", "C04", "F03", "H01", "D05"] 57 | rotated = rotator.rotate_cw(original) 58 | np.testing.assert_array_equal(expected, rotated) 59 | return 60 | 61 | def test_counterclockwise(self) -> None: 62 | A = (6, 8) 63 | rotator = WellRotator(A) 64 | 65 | original = ["A01", "C03", "D06", "F08", "B04"] 66 | expected = ["H01", "F03", "C04", "A06", "E02"] 67 | rotated = rotator.rotate_ccw(original) 68 | np.testing.assert_array_equal(expected, rotated) 69 | return 70 | 71 | 72 | class TestWellRandomizer: 73 | def test_init(self): 74 | randomizer = WellRandomizer(original_shape=(1, 4), random_seed=13) 75 | assert randomizer.original_shape == (1, 4) 76 | assert randomizer.random_seed == 13 77 | np.testing.assert_array_equal(randomizer.randomized_wells, ["A02", "A04", "A01", "A03"]) 78 | return 79 | 80 | def test_randomize_wells(self) -> None: 81 | A = (6, 8) 82 | S = 13 83 | randomizer = WellRandomizer(A, S) 84 | original = ["A01", "A02", "A03", "A04", "A05", "A06"] 85 | expected = ["A01", "F02", "A05", "A07", "B07", "D06"] 86 | randomized = randomizer.randomize_wells(original) 87 | np.testing.assert_array_equal(expected, randomized) 88 | return 89 | 90 | def test_derandomize_wells(self) -> None: 91 | A = (6, 8) 92 | S = 13 93 | randomizer = WellRandomizer(A, S) 94 | original = ["A01", "F02", "A05", "A07", "B07", "D06"] 95 | expected = ["A01", "A02", "A03", "A04", "A05", "A06"] 96 | derandomized = randomizer.derandomize_wells(original) 97 | np.testing.assert_array_equal(expected, derandomized) 98 | return 99 | 100 | def test_derandomize_wells_bug_29(self) -> None: 101 | A = (6, 8) 102 | S = 13 103 | randomizer = WellRandomizer(A, S) 104 | original = ["A01", "F02", "A05", "A07", "B07", "D06"][::-1] 105 | expected = ["A01", "A02", "A03", "A04", "A05", "A06"][::-1] 106 | derandomized = randomizer.derandomize_wells(original) 107 | np.testing.assert_array_equal(expected, derandomized) 108 | return 109 | 110 | def test_randomize_wells_in_row(self) -> None: 111 | A = (6, 8) 112 | S = 13 113 | randomizer = WellRandomizer(A, S, mode="row") 114 | original = ["A01", "A02", "A03", "B01", "C02", "B04"] 115 | expected = ["A02", "A05", "A04", "B08", "C05", "B06"] 116 | randomized = randomizer.randomize_wells(original) 117 | np.testing.assert_array_equal(expected, randomized) 118 | return 119 | 120 | def test_derandomize_wells_in_row(self) -> None: 121 | A = (6, 8) 122 | S = 13 123 | randomizer = WellRandomizer(A, S, mode="row") 124 | original = ["A02", "A05", "A04", "B08", "C05", "B06"] 125 | expected = ["A01", "A02", "A03", "B01", "C02", "B04"] 126 | randomized = randomizer.derandomize_wells(original) 127 | np.testing.assert_array_equal(expected, randomized) 128 | return 129 | 130 | def test_randomize_wells_in_column(self) -> None: 131 | A = (6, 8) 132 | S = 13 133 | randomizer = WellRandomizer(A, S, mode="column") 134 | original = ["A01", "A02", "A03", "B01", "C02", "B04"] 135 | expected = ["B01", "D02", "B03", "D01", "A02", "E04"] 136 | randomized = randomizer.randomize_wells(original) 137 | np.testing.assert_array_equal(expected, randomized) 138 | return 139 | 140 | def test_derandomize_wells_in_column(self) -> None: 141 | A = (6, 8) 142 | S = 13 143 | randomizer = WellRandomizer(A, S, mode="column") 144 | original = ["B01", "D02", "B03", "D01", "A02", "E04"] 145 | expected = ["A01", "A02", "A03", "B01", "C02", "B04"] 146 | randomized = randomizer.derandomize_wells(original) 147 | np.testing.assert_array_equal(expected, randomized) 148 | return 149 | -------------------------------------------------------------------------------- /robotools/test_utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from robotools.evotools import EvoWorklist 5 | from robotools.liquidhandling import Labware, Trough 6 | from robotools.utils import DilutionPlan, get_trough_wells 7 | 8 | 9 | class TestDilutionPlan: 10 | def test_argchecking(self) -> None: 11 | with pytest.raises(ValueError): 12 | DilutionPlan(xmin=0.001, xmax=30, R=8, C=12, stock=20, mode="log", vmax=1000, min_transfer=20) 13 | 14 | with pytest.raises(ValueError): 15 | DilutionPlan(xmin=0.001, xmax=30, R=8, C=12, stock=30, mode="invalid", vmax=1000, min_transfer=20) 16 | 17 | with pytest.raises(ValueError): 18 | DilutionPlan(xmin=0.001, xmax=30, R=6, C=4, stock=30, mode="linear", vmax=1000, min_transfer=20) 19 | 20 | with pytest.raises(ValueError): 21 | DilutionPlan( 22 | xmin=0.001, 23 | xmax=30, 24 | R=6, 25 | C=4, 26 | stock=30, 27 | mode="linear", 28 | vmax=[1000, 1000, 1000], 29 | min_transfer=20, 30 | ) 31 | 32 | return 33 | 34 | def test_repr(self) -> None: 35 | plan = DilutionPlan(xmin=0.001, xmax=30, R=8, C=12, stock=30, mode="log", vmax=1000, min_transfer=20) 36 | 37 | out = plan.__repr__() 38 | 39 | assert out is not None 40 | assert isinstance(out, str) 41 | return 42 | 43 | def test_issue_48(self): 44 | """Columns are named 1-based, therefore the "from column" must be too.""" 45 | plan = DilutionPlan(xmin=0.3, xmax=30, stock=30, R=1, C=3, mode="log", vmax=100, min_transfer=10) 46 | np.testing.assert_allclose(plan.x, [[30, 3, 0.3]]) 47 | # Reformat instructions for easier equals comparison 48 | instructions = [(col, dsteps, pfrom, list(tvols)) for col, dsteps, pfrom, tvols in plan.instructions] 49 | assert instructions == [ 50 | (0, 0, "stock", [100.0]), 51 | (1, 0, "stock", [10.0]), # The previous column is equal to the stock! 52 | (2, 1, 1, [10.0]), 53 | ] 54 | plan_s = str(plan) 55 | lines = plan_s.split("\n") 56 | assert "Prepare column 1" in lines[1] 57 | assert "Prepare column 2" in lines[2] 58 | assert "Prepare column 3" in lines[3] 59 | assert "from stock" in lines[1] 60 | assert "from stock" in lines[2] 61 | assert "from column 2" in lines[3] 62 | pass 63 | 64 | def test_linear_plan(self) -> None: 65 | plan = DilutionPlan(xmin=1, xmax=10, R=10, C=1, stock=20, mode="linear", vmax=1000, min_transfer=20) 66 | 67 | np.testing.assert_array_equal(plan.x, plan.ideal_x) 68 | assert plan.max_steps == 0 69 | assert plan.v_stock == 2750 70 | assert plan.instructions[0][0] == 0 71 | assert plan.instructions[0][1] == 0 72 | assert plan.instructions[0][2] == "stock" 73 | np.testing.assert_array_equal( 74 | plan.instructions[0][3], 75 | [ 76 | 500, 77 | 450, 78 | 400, 79 | 350, 80 | 300, 81 | 250, 82 | 200, 83 | 150, 84 | 100, 85 | 50, 86 | ], 87 | ) 88 | return 89 | 90 | def test_log_plan(self) -> None: 91 | plan = DilutionPlan(xmin=0.01, xmax=10, R=4, C=3, stock=20, mode="log", vmax=1000, min_transfer=20) 92 | 93 | assert np.allclose(plan.x, plan.ideal_x, rtol=0.05) 94 | assert plan.max_steps == 2 95 | assert plan.v_stock == 985 96 | assert plan.instructions[0][0] == 0 97 | assert plan.instructions[0][1] == 0 98 | assert plan.instructions[0][2] == "stock" 99 | np.testing.assert_array_equal(plan.instructions[0][3], [500, 267, 142, 76]) 100 | np.testing.assert_array_equal(plan.instructions[1][3], [82, 82, 82, 82]) 101 | np.testing.assert_array_equal(plan.instructions[2][3], [81, 81, 81, 81]) 102 | return 103 | 104 | def test_vector_vmax(self) -> None: 105 | plan = DilutionPlan( 106 | xmin=0.01, xmax=10, R=4, C=3, stock=20, mode="log", vmax=[1000, 500, 1500], min_transfer=20 107 | ) 108 | 109 | assert np.allclose(plan.x, plan.ideal_x, rtol=0.05) 110 | assert plan.max_steps == 2 111 | assert plan.v_stock == 985 112 | assert plan.instructions[0][0] == 0 113 | assert plan.instructions[0][1] == 0 114 | assert plan.instructions[0][2] == "stock" 115 | np.testing.assert_array_equal(plan.instructions[0][3], [500, 267, 142, 76]) 116 | np.testing.assert_array_equal(plan.instructions[1][3], [41, 41, 41, 41]) 117 | np.testing.assert_array_equal(plan.instructions[2][3], [121, 121, 121, 121]) 118 | return 119 | 120 | def test_to_worklist(self) -> None: 121 | # this test case tries to make it as hard as possible for the `to_worklist` method: 122 | # + vmax is different in every column 123 | # + stock has 2 rows, but dilution plan has 3 124 | # + diluent has 4 rows but dilution plan has 3 125 | # + diluent is not in the first column of a multi-column trough 126 | # + dilution plate is bigger than the plan 127 | # + destination plate is bigger than the plan 128 | stock_concentration = 20 129 | plan = DilutionPlan( 130 | xmin=0.01, 131 | xmax=10, 132 | R=3, 133 | C=4, 134 | stock=stock_concentration, 135 | mode="log", 136 | vmax=[1000, 1900, 980, 500], 137 | min_transfer=50, 138 | ) 139 | stock = Trough("Stock", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000) 140 | diluent = Trough("Diluent", 4, 2, min_volume=0, max_volume=20_000, initial_volumes=[0, 20_000]) 141 | dilution = Labware("Dilution", 6, 8, min_volume=0, max_volume=2000) 142 | destination = Labware("Destination", 7, 10, min_volume=0, max_volume=1000) 143 | with EvoWorklist() as wl: 144 | plan.to_worklist( 145 | worklist=wl, 146 | stock=stock, 147 | stock_column=0, 148 | diluent=diluent, 149 | diluent_column=1, 150 | dilution_plate=dilution, 151 | destination_plate=destination, 152 | v_destination=200, 153 | mix_volume=0.75, 154 | ) 155 | # assert the achieved concentrations in the destination 156 | np.testing.assert_array_almost_equal( 157 | plan.x, destination.composition["Stock"][: plan.R, : plan.C] * stock_concentration 158 | ) 159 | assert "Mix column 0 with 75 % of its volume" in dilution.report 160 | assert "Mix column 1 with 50 % of its volume" in dilution.report 161 | return 162 | 163 | def test_to_worklist_hooks(self) -> None: 164 | stock_concentration = 123 165 | plan = DilutionPlan( 166 | xmin=1, 167 | xmax=123, 168 | R=3, 169 | C=4, 170 | stock=stock_concentration, 171 | mode="log", 172 | vmax=1000, 173 | min_transfer=50, 174 | ) 175 | stock = Trough("Stock", 2, 1, min_volume=0, max_volume=10000, initial_volumes=10000) 176 | diluent = Trough("Diluent", 4, 2, min_volume=0, max_volume=10000, initial_volumes=[0, 10000]) 177 | dilution = Labware("Dilution", 3, 4, min_volume=0, max_volume=2000) 178 | 179 | # Multiple destinations; transferred to via a hook 180 | destinations = [ 181 | Labware("DestinationOne", 3, 2, min_volume=0, max_volume=1000), 182 | Labware("DestinationTwo", 3, 2, min_volume=0, max_volume=1000), 183 | ] 184 | 185 | # Split the work across two worklists (also via the hook) 186 | wl_one = EvoWorklist() 187 | wl_two = EvoWorklist() 188 | 189 | def pre_mix(col, wl): 190 | wl.comment(f"Pre-mix on column {col}") 191 | if col == 1: 192 | return wl_two 193 | 194 | def post_mix(col, wl): 195 | wl.comment(f"Post-mix on column {col}") 196 | if col == 1: 197 | wl.transfer( 198 | dilution, 199 | dilution.wells[:, 0:2], 200 | destinations[0], 201 | destinations[0].wells[:, :], 202 | volumes=100, 203 | ) 204 | elif col == 3: 205 | wl.transfer( 206 | dilution, 207 | dilution.wells[:, 2:4], 208 | destinations[1], 209 | destinations[1].wells[:, :], 210 | volumes=100, 211 | ) 212 | 213 | plan.to_worklist( 214 | worklist=wl_one, 215 | stock=stock, 216 | stock_column=0, 217 | diluent=diluent, 218 | diluent_column=1, 219 | dilution_plate=dilution, 220 | pre_mix_hook=pre_mix, 221 | post_mix_hook=post_mix, 222 | ) 223 | 224 | assert len(wl_two) > 0 225 | assert "C;Pre-mix on column 0" in wl_one 226 | assert "C;Pre-mix on column 1" in wl_one 227 | assert "C;Pre-mix on column 2" in wl_two 228 | assert "C;Pre-mix on column 3" in wl_two 229 | 230 | assert "C;Post-mix on column 0" in wl_one 231 | assert "C;Post-mix on column 1" in wl_two # worklist switched in the mix hook! 232 | assert "C;Post-mix on column 2" in wl_two 233 | assert "C;Post-mix on column 3" in wl_two 234 | 235 | np.testing.assert_almost_equal( 236 | destinations[0].composition["Stock"] * stock_concentration, plan.x[:, [0, 1]] 237 | ) 238 | np.testing.assert_almost_equal( 239 | destinations[1].composition["Stock"] * stock_concentration, plan.x[:, [2, 3]] 240 | ) 241 | return 242 | 243 | 244 | class TestUtils: 245 | def test_get_trough_wells(self) -> None: 246 | with pytest.raises(ValueError): 247 | get_trough_wells(n=-1, trough_wells=list("ABC")) 248 | with pytest.raises(ValueError): 249 | get_trough_wells(n=3, trough_wells=[]) 250 | with pytest.raises(TypeError): 251 | get_trough_wells(n=0.5, trough_wells=list("ABC")) 252 | assert get_trough_wells(n=2, trough_wells="ABC") == ["ABC", "ABC"] 253 | np.testing.assert_array_equal(get_trough_wells(n=0, trough_wells=list("ABC")), list()) 254 | np.testing.assert_array_equal(get_trough_wells(n=1, trough_wells=list("ABC")), list("A")) 255 | np.testing.assert_array_equal(get_trough_wells(n=3, trough_wells=list("ABC")), list("ABC")) 256 | np.testing.assert_array_equal(get_trough_wells(n=4, trough_wells=list("ABC")), list("ABCA")) 257 | np.testing.assert_array_equal(get_trough_wells(n=7, trough_wells=list("ABC")), list("ABCABCA")) 258 | return 259 | -------------------------------------------------------------------------------- /robotools/test_worklists.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import pytest 4 | 5 | from robotools import ( 6 | BaseWorklist, 7 | CompatibilityError, 8 | EvoWorklist, 9 | FluentWorklist, 10 | Worklist, 11 | ) 12 | 13 | 14 | def test_worklist_inheritance(): 15 | assert issubclass(BaseWorklist, list) 16 | assert issubclass(EvoWorklist, BaseWorklist) 17 | assert issubclass(FluentWorklist, BaseWorklist) 18 | assert issubclass(Worklist, EvoWorklist) 19 | pass 20 | 21 | 22 | def test_worklist_deprecation(): 23 | with pytest.warns(DeprecationWarning, match="please switch to"): 24 | Worklist() 25 | pass 26 | 27 | 28 | def test_recommended_instantiation(): 29 | with warnings.catch_warnings(): 30 | warnings.simplefilter("error") 31 | BaseWorklist() 32 | EvoWorklist() 33 | FluentWorklist() 34 | pass 35 | 36 | 37 | def test_base_worklist_cant_transfer(): 38 | with BaseWorklist() as wl: 39 | with pytest.raises(CompatibilityError, match="specific, but this object"): 40 | wl.transfer(None, "A01", None, "B01", 100) 41 | pass 42 | -------------------------------------------------------------------------------- /robotools/transform.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Literal, Tuple 2 | 3 | import numpy 4 | from numpy.typing import ArrayLike 5 | 6 | 7 | def make_well_index_dict(R: int, C: int) -> Dict[str, Tuple[int, int]]: 8 | """Create a dictionary mapping well IDs to their numpy indices. 9 | 10 | Parameters 11 | ---------- 12 | R : int 13 | Number of rows 14 | C : int 15 | Number of columns 16 | 17 | Returns 18 | ------- 19 | indices : dict 20 | Mapping of IDs to numpy-style indices 21 | """ 22 | return { 23 | f"{row}{column:02d}": (r, c) 24 | for r, row in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZ"[:R]) 25 | for c, column in enumerate(range(1, C + 1)) 26 | } 27 | 28 | 29 | def make_well_array(R: int, C: int) -> numpy.ndarray: 30 | """Create a numpy array of well IDs. 31 | 32 | Parameters 33 | ---------- 34 | R : int 35 | Number of rows 36 | C : int 37 | Number of columns 38 | 39 | Returns 40 | ------- 41 | array : ndarray 42 | Array of well IDs 43 | """ 44 | return numpy.array( 45 | [[f"{row}{column:02d}" for column in range(1, C + 1)] for row in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[:R]] 46 | ) 47 | 48 | 49 | class WellShifter: 50 | """Helper object to shift a set of well IDs within a MTP.""" 51 | 52 | def __init__(self, shape_A: Tuple[int, int], shape_B: Tuple[int, int], shifted_A01: str) -> None: 53 | """Create a helper object for shifting wells around. 54 | 55 | Parameters 56 | ---------- 57 | shape_A : tuple 58 | (n_rows, n_cols) of the source labware 59 | shape_B : tuple 60 | (n_rows, n_cols) of the destination labware 61 | shifted_A01 : str 62 | Well ID on B where the A01 from A ends up 63 | """ 64 | self.shifted_A01 = shifted_A01 65 | self.shape_A = shape_A 66 | self.shape_B = shape_B 67 | self.indices_A = make_well_index_dict(*self.shape_A) 68 | self.indices_B = make_well_index_dict(*self.shape_B) 69 | self.wells_A = make_well_array(*self.shape_A) 70 | self.wells_B = make_well_array(*self.shape_B) 71 | self.dr, self.dc = self.indices_B[shifted_A01] 72 | 73 | if shape_A[0] + self.dr > shape_B[0]: 74 | raise ValueError(f"Invalid shift parameterization. Not enough rows in destination.") 75 | if shape_A[1] + self.dc > shape_B[1]: 76 | raise ValueError(f"Invalid shift parameterization. Not enough columns in destination.") 77 | 78 | def shift(self, wells: ArrayLike) -> numpy.ndarray: 79 | """Apply the forward-transformation. 80 | 81 | Parameters 82 | ---------- 83 | wells : array-like 84 | List or array of well ids on A 85 | 86 | Returns 87 | ------- 88 | shifted : ndarray 89 | Array of well ids on B (same shape) 90 | """ 91 | wells = numpy.array(wells) 92 | wells_shape = wells.shape 93 | 94 | shifted = [] 95 | for well in wells.flatten(): 96 | r, c = self.indices_A[well] 97 | shifted.append(self.wells_B[r + self.dr, c + self.dc]) 98 | return numpy.array(shifted).reshape(wells_shape) 99 | 100 | def unshift(self, wells: ArrayLike) -> numpy.ndarray: 101 | """Apply the reverse-transformation. 102 | 103 | Parameters 104 | ---------- 105 | wells : array-like 106 | List or array of well ids on B 107 | 108 | Returns 109 | ------- 110 | original : ndarray 111 | Array of well ids on A (same shape) 112 | """ 113 | wells = numpy.array(wells) 114 | wells_shape = wells.shape 115 | 116 | shifted = [] 117 | for well in wells.flatten(): 118 | r, c = self.indices_B[well] 119 | shifted.append(self.wells_A[r - self.dr, c - self.dc]) 120 | return numpy.array(shifted).reshape(wells_shape) 121 | 122 | 123 | class WellRotator: 124 | """Helper object to rotate a set of well IDs within a MTP.""" 125 | 126 | def __init__(self, original_shape: Tuple[int, int]) -> None: 127 | """Create a helper object for shifting wells around. 128 | 129 | Parameters 130 | ---------- 131 | original_shape : tuple 132 | (n_rows, n_cols) of all wells in the source labware 133 | """ 134 | self.original_shape = original_shape 135 | self.rotated_shape = original_shape[::-1] 136 | self.original_indices = make_well_index_dict(*self.original_shape) 137 | self.rotated_indices = make_well_index_dict(*self.rotated_shape) 138 | self.original_wells = make_well_array(*self.original_shape) 139 | self.rotated_wells = make_well_array(*self.rotated_shape) 140 | super().__init__() 141 | 142 | def rotate_ccw(self, wells: ArrayLike) -> numpy.ndarray: 143 | """Rotate the given wells counterclockwise. 144 | 145 | Parameters 146 | ---------- 147 | wells : array-like 148 | List or array of well ids 149 | 150 | Returns 151 | ------- 152 | rotated : ndarray 153 | Array of well ids 154 | """ 155 | wells = numpy.array(wells) 156 | wells_shape = wells.shape 157 | 158 | rotated = [] 159 | for well in wells.flatten(): 160 | r, c = self.original_indices[well] 161 | rotated.append(self.rotated_wells[self.original_shape[1] - c - 1, r]) 162 | return numpy.array(rotated).reshape(wells_shape) 163 | 164 | def rotate_cw(self, wells: ArrayLike) -> numpy.ndarray: 165 | """Rotate the given wells clockwise. 166 | 167 | Parameters 168 | ---------- 169 | wells : array-like 170 | List or array of well ids 171 | 172 | Returns 173 | ------- 174 | rotated : ndarray 175 | Array of well ids 176 | """ 177 | wells = numpy.array(wells) 178 | wells_shape = wells.shape 179 | 180 | rotated = [] 181 | for well in wells.flatten(): 182 | r, c = self.original_indices[well] 183 | rotated.append(self.rotated_wells[c, self.original_shape[0] - r - 1]) 184 | return numpy.array(rotated).reshape(wells_shape) 185 | 186 | 187 | class WellRandomizer: 188 | """Helper object to randomize a set of well IDs within a MTP.""" 189 | 190 | def __init__( 191 | self, 192 | original_shape: Tuple[int, int], 193 | random_seed: int, 194 | *, 195 | mode: Literal["full", "row", "column"] = "full", 196 | ) -> None: 197 | """Create a helper object for randomizing wells. 198 | 199 | Parameters 200 | ---------- 201 | original_shape 202 | (n_rows, n_cols) of all wells in the source labware 203 | random_seed 204 | Integer for defined and reproduceable randomization 205 | mode 206 | To switch between `"full"` randomization, 207 | or randomization only within each `"row"`, 208 | or randomization only within each `"column"`. 209 | """ 210 | self.original_shape = original_shape 211 | self.random_seed = random_seed 212 | self.rng = numpy.random.RandomState(self.random_seed) 213 | self.lookup: Dict[str, str] = {} 214 | full = make_well_array(*self.original_shape) 215 | if mode == "full": 216 | self.original_wells = full.flatten() 217 | self.randomized_wells = [] 218 | self.randomized_wells = self.rng.permutation(self.original_wells).tolist() 219 | self.lookup = {owell: rwell for owell, rwell in zip(self.original_wells, self.randomized_wells)} 220 | elif mode == "row": 221 | for r in range(self.original_shape[0]): 222 | rowwells = full[r, :] 223 | randomized = self.rng.permutation(rowwells) 224 | for owell, rwell in zip(rowwells, randomized): 225 | self.lookup[owell] = rwell 226 | elif mode == "column": 227 | self.original_wells = [] 228 | for c in range(self.original_shape[1]): 229 | columnwells = full[:, c] 230 | randomized = self.rng.permutation(columnwells) 231 | for owell, rwell in zip(columnwells, randomized): 232 | self.lookup[owell] = rwell 233 | else: 234 | raise ValueError(f"Unsupported mode: {mode}") 235 | self.lookup_reverse = {rwell: owell for owell, rwell in self.lookup.items()} 236 | super().__init__() 237 | 238 | def randomize_wells(self, wells: ArrayLike) -> numpy.ndarray: 239 | """Randomize the given wells with the random state and assignment specified in __init__. 240 | 241 | Parameters 242 | ---------- 243 | wells : array-like 244 | List or array of well ids 245 | 246 | Returns 247 | ------- 248 | randomized : ndarray 249 | Array of well ids 250 | """ 251 | 252 | input_wells = numpy.array(wells) 253 | wells_shape = input_wells.shape 254 | randomized_output_wells = [self.lookup.get(well) for well in input_wells] 255 | 256 | return numpy.array(randomized_output_wells).reshape(wells_shape) 257 | 258 | def derandomize_wells(self, wells: ArrayLike) -> numpy.ndarray: 259 | """Derandomize the given wells with the random state and assignment specified in __init__. 260 | 261 | Parameters 262 | ---------- 263 | wells : array-like 264 | List or array of well ids 265 | 266 | Returns 267 | ------- 268 | derandomized_output_wells : ndarray 269 | Array of well ids 270 | """ 271 | input_wells = numpy.array(wells) 272 | wells_shape = input_wells.shape 273 | derandomized_output_wells = [self.lookup_reverse.get(well) for well in input_wells] 274 | 275 | return numpy.array(derandomized_output_wells).reshape(wells_shape) 276 | -------------------------------------------------------------------------------- /robotools/worklists/__init__.py: -------------------------------------------------------------------------------- 1 | from robotools.worklists.base import BaseWorklist 2 | from robotools.worklists.exceptions import CompatibilityError, InvalidOperationError 3 | -------------------------------------------------------------------------------- /robotools/worklists/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions related to liquid handling with worklists.""" 2 | 3 | __all__ = ( 4 | "CompatibilityError", 5 | "InvalidOperationError", 6 | ) 7 | 8 | 9 | class CompatibilityError(NotImplementedError): 10 | """Exception that's thrown when device-specific implementations are required.""" 11 | 12 | 13 | class InvalidOperationError(Exception): 14 | """When an operation cannot be performed under the present circumstances.""" 15 | -------------------------------------------------------------------------------- /robotools/worklists/test_lvh.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from robotools.evotools.worklist import EvoWorklist 5 | from robotools.fluenttools.worklist import FluentWorklist 6 | from robotools.liquidhandling.labware import Labware 7 | 8 | 9 | class TestLargeVolumeHandling: 10 | @pytest.mark.parametrize("cls", [EvoWorklist, FluentWorklist]) 11 | def test_single_split(self, cls) -> None: 12 | src = Labware("A", 3, 2, min_volume=1000, max_volume=25000, initial_volumes=12000) 13 | dst = Labware("B", 3, 2, min_volume=1000, max_volume=25000) 14 | with cls(auto_split=True) as wl: 15 | wl.transfer(src, "A01", dst, "A01", 2000, label="Transfer more than 2x the max") 16 | assert wl == [ 17 | "C;Transfer more than 2x the max", 18 | "A;A;;;1;;667.00;;;;", 19 | "D;B;;;1;;667.00;;;;", 20 | "W1;", 21 | # no breaks when pipetting single wells 22 | "A;A;;;1;;667.00;;;;", 23 | "D;B;;;1;;667.00;;;;", 24 | "W1;", 25 | # no breaks when pipetting single wells 26 | "A;A;;;1;;666.00;;;;", 27 | "D;B;;;1;;666.00;;;;", 28 | "W1;", 29 | "B;", # always break after partitioning 30 | ] 31 | # Two extra steps were necessary because of LVH 32 | assert "Transfer more than 2x the max (2 LVH steps)" in src.report 33 | assert "Transfer more than 2x the max (2 LVH steps)" in dst.report 34 | np.testing.assert_array_equal( 35 | src.volumes, 36 | [ 37 | [12000 - 2000, 12000], 38 | [12000, 12000], 39 | [12000, 12000], 40 | ], 41 | ) 42 | np.testing.assert_array_equal( 43 | dst.volumes, 44 | [ 45 | [2000, 0], 46 | [0, 0], 47 | [0, 0], 48 | ], 49 | ) 50 | return 51 | 52 | @pytest.mark.parametrize("cls", [EvoWorklist, FluentWorklist]) 53 | def test_column_split(self, cls) -> None: 54 | src = Labware("A", 4, 2, min_volume=1000, max_volume=25000, initial_volumes=12000) 55 | dst = Labware("B", 4, 2, min_volume=1000, max_volume=25000) 56 | with cls(auto_split=True) as wl: 57 | wl.transfer( 58 | src, ["A01", "B01", "D01", "C01"], dst, ["A01", "B01", "D01", "C01"], [1500, 250, 0, 1200] 59 | ) 60 | assert wl == [ 61 | "A;A;;;1;;750.00;;;;", 62 | "D;B;;;1;;750.00;;;;", 63 | "W1;", 64 | "A;A;;;2;;250.00;;;;", 65 | "D;B;;;2;;250.00;;;;", 66 | "W1;", 67 | # D01 is ignored because the volume is 0 68 | "A;A;;;3;;600.00;;;;", 69 | "D;B;;;3;;600.00;;;;", 70 | "W1;", 71 | "B;", # within-column break 72 | "A;A;;;1;;750.00;;;;", 73 | "D;B;;;1;;750.00;;;;", 74 | "W1;", 75 | "A;A;;;3;;600.00;;;;", 76 | "D;B;;;3;;600.00;;;;", 77 | "W1;", 78 | "B;", # tailing break after partitioning 79 | ] 80 | np.testing.assert_array_equal( 81 | src.volumes, 82 | [ 83 | [12000 - 1500, 12000], 84 | [12000 - 250, 12000], 85 | [12000 - 1200, 12000], 86 | [12000, 12000], 87 | ], 88 | ) 89 | np.testing.assert_array_equal( 90 | dst.volumes, 91 | [ 92 | [1500, 0], 93 | [250, 0], 94 | [1200, 0], 95 | [0, 0], 96 | ], 97 | ) 98 | return 99 | 100 | @pytest.mark.parametrize("cls", [EvoWorklist, FluentWorklist]) 101 | def test_block_split(self, cls) -> None: 102 | src = Labware("A", 3, 2, min_volume=1000, max_volume=25000, initial_volumes=12000) 103 | dst = Labware("B", 3, 2, min_volume=1000, max_volume=25000) 104 | with cls(auto_split=True) as wl: 105 | wl.transfer( 106 | # A01, B01, A02, B02 107 | src, 108 | src.wells[:2, :], 109 | dst, 110 | ["A01", "B01", "C01", "A02"], 111 | [1500, 250, 1200, 3000], 112 | ) 113 | assert wl == [ 114 | "A;A;;;1;;750.00;;;;", 115 | "D;B;;;1;;750.00;;;;", 116 | "W1;", 117 | "A;A;;;2;;250.00;;;;", 118 | "D;B;;;2;;250.00;;;;", 119 | "W1;", 120 | "B;", # within-column 1 break 121 | "A;A;;;1;;750.00;;;;", 122 | "D;B;;;1;;750.00;;;;", 123 | "W1;", 124 | "B;", # between-column 1/2 break 125 | "A;A;;;4;;600.00;;;;", 126 | "D;B;;;3;;600.00;;;;", 127 | "W1;", 128 | "A;A;;;5;;750.00;;;;", 129 | "D;B;;;4;;750.00;;;;", 130 | "W1;", 131 | "B;", # within-column 2 break 132 | "A;A;;;4;;600.00;;;;", 133 | "D;B;;;3;;600.00;;;;", 134 | "W1;", 135 | "A;A;;;5;;750.00;;;;", 136 | "D;B;;;4;;750.00;;;;", 137 | "W1;", 138 | "B;", # within-column 2 break 139 | "A;A;;;5;;750.00;;;;", 140 | "D;B;;;4;;750.00;;;;", 141 | "W1;", 142 | # no break because only one well is accessed in this partition 143 | "A;A;;;5;;750.00;;;;", 144 | "D;B;;;4;;750.00;;;;", 145 | "W1;", 146 | "B;", # tailing break after partitioning 147 | ] 148 | 149 | # How the number of splits is calculated: 150 | # 1500 is split 2x → 1 extra 151 | # 250 is not split 152 | # 1200 is split 2x → 1 extra 153 | # 3000 is split 4x → 3 extra 154 | # Sum of extra steps: 5 155 | assert "5 LVH steps" in src.report 156 | assert "5 LVH steps" in dst.report 157 | np.testing.assert_array_equal( 158 | src.volumes, 159 | [ 160 | [12000 - 1500, 12000 - 1200], 161 | [12000 - 250, 12000 - 3000], 162 | [12000, 12000], 163 | ], 164 | ) 165 | np.testing.assert_array_equal( 166 | dst.volumes, 167 | [ 168 | [1500, 3000], 169 | [250, 0], 170 | [1200, 0], 171 | ], 172 | ) 173 | return 174 | -------------------------------------------------------------------------------- /robotools/worklists/test_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from robotools.liquidhandling.labware import Labware, Trough 4 | from robotools.worklists.utils import ( 5 | non_repetitive_argsort, 6 | optimize_partition_by, 7 | partition_by_column, 8 | ) 9 | 10 | 11 | def test_non_repetitive_argsort(): 12 | wells = "A01,C01,B01,B01,A01,B01".split(",") 13 | result = non_repetitive_argsort(wells) 14 | assert isinstance(result, list) 15 | assert all(isinstance(r, int) for r in result) 16 | assert len(result) == len(wells) 17 | assert result == [0, 2, 1, 4, 3, 5] 18 | pass 19 | 20 | 21 | def test_automatic_partitioning(caplog) -> None: 22 | S = Labware("S", 8, 2, min_volume=5000, max_volume=250 * 1000) 23 | D = Labware("D", 8, 2, min_volume=5000, max_volume=250 * 1000) 24 | ST = Trough("ST", 8, 2, min_volume=5000, max_volume=250 * 1000) 25 | DT = Trough("DT", 8, 2, min_volume=5000, max_volume=250 * 1000) 26 | 27 | # Expected behaviors: 28 | # + always keep settings other than 'auto' 29 | # + warn user about inefficient configuration (when user selects to partition by the trough) 30 | 31 | # automatic 32 | assert "source" == optimize_partition_by(S, D, "auto", "No troughs at all") 33 | assert "source" == optimize_partition_by(S, DT, "auto", "Trough destination") 34 | assert "source" == optimize_partition_by(ST, D, "auto", "Trough source") 35 | optimize_partition_by(ST, DT, "auto", "Trough source and destination") == "source" 36 | 37 | # fixed to source 38 | assert "source" == optimize_partition_by(S, D, "source", "No troughs at all") 39 | assert "source" == optimize_partition_by(S, DT, "source", "Trough destination") 40 | with caplog.at_level(logging.WARNING, logger="robotools.evotools"): 41 | assert "source" == optimize_partition_by(ST, D, "source", "Trough source") 42 | assert 'Consider using partition_by="destination"' in caplog.records[0].message 43 | optimize_partition_by(ST, DT, "auto", "Trough source and destination") == "source" 44 | 45 | # fixed to destination 46 | optimize_partition_by(S, D, "destination", "No troughs at all") == "destination" 47 | caplog.clear() 48 | with caplog.at_level(logging.WARNING, logger="robotools.evotools"): 49 | assert optimize_partition_by(S, DT, "destination", "Trough destination") == "destination" 50 | assert 'Consider using partition_by="source"' in caplog.records[0].message 51 | assert optimize_partition_by(ST, D, "destination", "Trough source") == "destination" 52 | assert optimize_partition_by(ST, DT, "destination", "Trough source and destination") == "destination" 53 | return 54 | 55 | 56 | def test_partition_by_column_does_not_repeat_wells(): 57 | wells = "C01,B01,A01,B01,A01,C01,D02,C02".split(",") 58 | cgroups = partition_by_column( 59 | sources=wells, 60 | destinations=["A01"] * 8, 61 | volumes=[1, 2, 3, 4, 5, 6, 7, 8], 62 | partition_by="source", 63 | ) 64 | assert len(cgroups) == 2 65 | assert cgroups[0][0] == "A01,B01,C01,A01,B01,C01".split(",") 66 | assert cgroups[0][1] == ["A01"] * 6 67 | assert cgroups[0][2] == [3, 2, 1, 5, 4, 6] 68 | assert cgroups[1][0] == "C02,D02".split(",") 69 | assert cgroups[1][1] == ["A01"] * 2 70 | assert cgroups[1][2] == [8, 7] 71 | pass 72 | -------------------------------------------------------------------------------- /robotools/worklists/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions that are relevant for worklist commands.""" 2 | import collections 3 | import logging 4 | import math 5 | from typing import Dict, Iterable, List, Literal, Optional, Sequence, Tuple, Union 6 | 7 | import numpy 8 | 9 | from robotools.evotools.types import Tip, int_to_tip 10 | from robotools.worklists.exceptions import InvalidOperationError 11 | 12 | from .. import liquidhandling 13 | 14 | __all__ = ( 15 | "prepare_aspirate_dispense_parameters", 16 | "optimize_partition_by", 17 | "partition_volume", 18 | "partition_by_column", 19 | ) 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def prepare_aspirate_dispense_parameters( 25 | rack_label: str, 26 | position: int, 27 | volume: float, 28 | liquid_class: str = "", 29 | tip: Union[Tip, int, collections.abc.Iterable] = Tip.Any, 30 | rack_id: str = "", 31 | tube_id: str = "", 32 | rack_type: str = "", 33 | forced_rack_type: str = "", 34 | max_volume: Optional[Union[int, float]] = None, 35 | ) -> Tuple[str, int, str, str, Union[Tip, int, collections.abc.Iterable], str, str, str, str]: 36 | """Validates and prepares aspirate/dispense parameters. 37 | 38 | Parameters 39 | ---------- 40 | rack_label : str 41 | User-defined labware name (max 32 characters) 42 | position : int 43 | Number of the well 44 | volume : float 45 | Volume in microliters (will be rounded to 2 decimal places) 46 | liquid_class : str, optional 47 | Overrides the liquid class for this step (max 32 characters) 48 | tip : Tip, int or Iterable of Tip / int, optional 49 | Tip that will be selected (Tip, 1-8 or Iterable of the former two) 50 | rack_id : str, optional 51 | Barcode of the labware (max 32 characters) 52 | tube_id : str, optional 53 | Barcode of the tube (max 32 characters) 54 | rack_type : str, optional 55 | Configuration name of the labware (max 32 characters). 56 | An error is raised if it missmatches with the underlying worktable. 57 | forced_rack_type : str, optional 58 | Overrides rack_type from worktable 59 | max_volume : int, optional 60 | Maximum allowed volume 61 | 62 | Returns 63 | ------- 64 | rack_label : str 65 | User-defined labware name (max 32 characters) 66 | position : int 67 | Number of the well 68 | volume : str 69 | Volume in microliters (will be rounded to 2 decimal places) 70 | liquid_class : str 71 | Overrides the liquid class for this step (max 32 characters) 72 | tip : Tip, int or Iterable of Tip / int 73 | Tip that will be selected (Tip, 1-8 or Iterable of the former two) 74 | rack_id : str 75 | Barcode of the labware (max 32 characters) 76 | tube_id : str 77 | Barcode of the tube (max 32 characters) 78 | rack_type : str 79 | Configuration name of the labware (max 32 characters). 80 | An error is raised if it missmatches with the underlying worktable. 81 | forced_rack_type : str 82 | Overrides rack_type from worktable 83 | """ 84 | # required parameters 85 | if rack_label is None: 86 | raise ValueError("Missing required parameter: rack_label") 87 | if not isinstance(rack_label, str) or len(rack_label) > 32 or ";" in rack_label: 88 | raise ValueError(f"Invalid rack_label: {rack_label}") 89 | 90 | if position is None: 91 | raise ValueError("Missing required parameter: position") 92 | if not isinstance(position, int) or position < 0: 93 | raise ValueError(f"Invalid position: {position}") 94 | 95 | if volume is None: 96 | raise ValueError("Missing required parameter: volume") 97 | try: 98 | volume = float(volume) 99 | except: 100 | raise ValueError(f"Invalid volume: {volume}") 101 | if volume < 0 or volume > 7158278 or numpy.isnan(volume): 102 | raise ValueError(f"Invalid volume: {volume}") 103 | if max_volume is not None and volume > max_volume: 104 | raise InvalidOperationError(f"Volume of {volume} exceeds max_volume.") 105 | 106 | # optional parameters 107 | if not isinstance(liquid_class, str) or ";" in liquid_class: 108 | raise ValueError(f"Invalid liquid_class: {liquid_class}") 109 | 110 | if isinstance(tip, int) and not isinstance(tip, Tip): 111 | # User-specified integers from 1-8 need to be converted to Tecan logic 112 | tip = int_to_tip(tip) 113 | 114 | if isinstance(tip, collections.abc.Iterable): 115 | tips = [] 116 | for element in tip: 117 | if isinstance(element, int) and not isinstance(element, Tip): 118 | tips.append(int_to_tip(element)) 119 | elif isinstance(element, Tip): 120 | if element == -1: 121 | raise ValueError( 122 | "When Iterables are used, no Tip.Any elements are allowed. Pass just one Tip.Any instead." 123 | ) 124 | tips.append(element) 125 | else: 126 | raise ValueError( 127 | f"If tip is an Iterable, it may only contain int or Tip values, not {type(element)}." 128 | ) 129 | tip = sum(set(tips)) 130 | elif not isinstance(tip, Tip): 131 | raise ValueError(f"tip must be an int between 1 and 8, Tip or Iterable, but was {type(tip)}.") 132 | 133 | if not isinstance(rack_id, str) or len(rack_id) > 32 or ";" in rack_id: 134 | raise ValueError(f"Invalid rack_id: {rack_id}") 135 | if not isinstance(rack_type, str) or len(rack_type) > 32 or ";" in rack_type: 136 | raise ValueError(f"Invalid rack_type: {rack_type}") 137 | if not isinstance(forced_rack_type, str) or len(forced_rack_type) > 32 or ";" in forced_rack_type: 138 | raise ValueError(f"Invalid forced_rack_type: {forced_rack_type}") 139 | 140 | # apply rounding and corrections for the right string formatting 141 | volume_str = f"{numpy.round(volume, decimals=2):.2f}" 142 | tip = "" if tip == -1 else tip 143 | return rack_label, position, volume_str, liquid_class, tip, rack_id, tube_id, rack_type, forced_rack_type 144 | 145 | 146 | def optimize_partition_by( 147 | source: liquidhandling.Labware, 148 | destination: liquidhandling.Labware, 149 | partition_by: str, 150 | label: Optional[str] = None, 151 | ) -> Literal["source", "destination"]: 152 | """Determines optimal partitioning settings. 153 | 154 | 155 | Parameters 156 | ---------- 157 | source 158 | Source labware object. 159 | destination 160 | Destination labware object. 161 | partition_by 162 | User-provided partitioning settings. 163 | label 164 | Label of the operation (optional). 165 | 166 | ---------- 167 | source (Labware): source labware object 168 | destination (Labware): destination labware object 169 | partition_by : str 170 | user-provided partitioning settings 171 | label : str 172 | label of the operation (optional) 173 | 174 | Returns 175 | ------- 176 | partition_by 177 | Either 'source' or 'destination' 178 | """ 179 | # automatic partitioning decision 180 | if partition_by == "auto": 181 | return "source" 182 | 183 | assert partition_by in {"source", "destination"} 184 | # log warnings about potentially inefficient partitioning settings 185 | if partition_by == "source": 186 | if source.is_trough and not destination.is_trough: 187 | logger.warning( 188 | 'Partitioning by "source" (%s), which is a Trough while destination (%s) is not a Trough.' 189 | ' This is potentially inefficient. Consider using partition_by="destination".' 190 | " (label=%s)", 191 | source.name, 192 | destination.name, 193 | label, 194 | ) 195 | return "source" 196 | elif partition_by == "destination": 197 | if destination.is_trough and not source.is_trough: 198 | logger.warning( 199 | 'Partitioning by "destination" (%s), which is a Trough while source (%s) is not a Trough.' 200 | ' This is potentially inefficient. Consider using partition_by="source"' 201 | " (label=%s)", 202 | destination.name, 203 | source.name, 204 | label, 205 | ) 206 | return "destination" 207 | raise ValueError(f"Invalid partition_by argument: {partition_by}") 208 | 209 | 210 | def partition_volume(volume: float, *, max_volume: Union[int, float]) -> List[float]: 211 | """Partitions a pipetting volume into zero or more integer-valued volumes that are <= max_volume. 212 | 213 | Parameters 214 | ---------- 215 | volume : float 216 | A volume to partition 217 | max_volume : int 218 | Maximum volume of a pipetting step 219 | 220 | Returns 221 | ------- 222 | volumes : list 223 | Partitioned volumes 224 | """ 225 | if volume == 0: 226 | return [] 227 | if volume < max_volume: 228 | return [volume] 229 | isteps = math.ceil(volume / max_volume) 230 | step_volume = math.ceil(volume / isteps) 231 | volumes: List[float] = [step_volume] * (isteps - 1) 232 | volumes.append(volume - numpy.sum(volumes)) 233 | return volumes 234 | 235 | 236 | def non_repetitive_argsort(wells: Sequence[str]) -> list[int]: 237 | """Argsort without repeating items with the same first letter.""" 238 | # Group wells by row 239 | by_row = collections.defaultdict(list) 240 | for iw, w in enumerate(wells): 241 | by_row[w[0]].append((iw, w)) 242 | by_row_sorted = {r: by_row[r] for r in sorted(by_row)} 243 | 244 | # Collect the original index of the first entry 245 | # from each row, until all rows are empty. 246 | results = [] 247 | while by_row_sorted: 248 | for r in list(by_row_sorted): 249 | row = by_row_sorted[r] 250 | iw, w = row.pop(0) 251 | results.append(iw) 252 | if not row: 253 | by_row_sorted.pop(r) 254 | return results 255 | 256 | 257 | def partition_by_column( 258 | sources: Iterable[str], 259 | destinations: Iterable[str], 260 | volumes: Iterable[float], 261 | partition_by: Literal["source", "destination"], 262 | ) -> List[Tuple[List[str], List[str], List[float]]]: 263 | """Partitions sources/destinations/volumes by the source column and sorts within those columns. 264 | 265 | Parameters 266 | ---------- 267 | sources : list 268 | The source well ids; same length as destinations and volumes 269 | destinations : list 270 | The destination well ids; same length as sources and volumes 271 | volumes : list 272 | The volumes; same length as sources and destinations 273 | partition_by : str 274 | Either 'source' or 'destination' 275 | 276 | Returns 277 | ------- 278 | column_groups : list 279 | A list of (sources, destinations, volumes) 280 | """ 281 | # first partition the wells into columns 282 | column_groups_dd: Dict[str, Tuple[List[str], List[str], List[float]]] = collections.defaultdict( 283 | lambda: ([], [], []) 284 | ) 285 | for s, d, v in zip(sources, destinations, volumes): 286 | if partition_by == "source": 287 | group = s[1:] 288 | elif partition_by == "destination": 289 | group = d[1:] 290 | else: 291 | raise ValueError(f'Invalid `partition_by` parameter "{partition_by}""') 292 | column_groups_dd[group][0].append(s) 293 | column_groups_dd[group][1].append(d) 294 | column_groups_dd[group][2].append(v) 295 | # bring columns in the right order 296 | column_groups = [column_groups_dd[col] for col in sorted(column_groups_dd.keys())] 297 | # sort the rows within the column 298 | for c, (srcs, dsts, vols) in enumerate(column_groups): 299 | if partition_by == "source": 300 | order = non_repetitive_argsort(srcs) 301 | elif partition_by == "destination": 302 | order = non_repetitive_argsort(dsts) 303 | else: 304 | raise ValueError(f'Invalid `partition_by` parameter "{partition_by}""') 305 | column_groups[c] = ( 306 | list(numpy.array(srcs)[order]), 307 | list(numpy.array(dsts)[order]), 308 | list(numpy.array(vols)[order]), 309 | ) 310 | return column_groups 311 | --------------------------------------------------------------------------------