Repository: dgretton/pyhamilton (language: python)

Issue:
iPSC suite



Discussion / hints:

Fix patch:
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..6419fd3
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,54 @@
+name: Build and Publish Documentation
+
+on:
+  push:
+    branches:
+      usb
+
+jobs:
+  build_docs:
+    name: Build documentation
+    runs-on: ubuntu-latest
+    env:
+      GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
+
+    steps:
+      - name: Checkout Code
+        uses: actions/checkout@v2
+
+      - name: Update packages
+        run: sudo apt-get update
+
+      - name: Set up Python 3.9
+        uses: actions/setup-python@v2
+        with:
+          python-version: 3.9
+
+      - name: Install Dependencies
+        run: pip install -e '.[docs]'
+
+      - name: Remove old documentation
+        run: |
+          rm -rf docs/build
+          rm -rf docs/_autosummary
+
+      - name: Build new documentation
+        run: sphinx-build -b html docs docs/build/ -j auto
+
+      - name: Commit generated documentation
+        run: |
+          cd docs/build
+          git init
+          touch .nojekyll
+          git add -A
+          git config --local user.name "GitHub Action"
+          git config --local user.email "action@github.com"
+          git commit -m 'deploy docs'
+
+      - name: Push to docs branch
+        uses: ad-m/github-push-action@master
+        with:
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+          branch: docs
+          force: true
+          directory: ./docs/build/
diff --git a/.gitignore b/.gitignore
index bfc3a7c..f5e5af2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@
 *.swp
 build/pdf-intermediate.md
 pyhamilton/LAY-BACKUP
+.ipynb_checkpoints
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 0000000..bee173e
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,442 @@
+# This Pylint rcfile contains a best-effort configuration to uphold the
+# best-practices and style described in the Google Python style guide:
+#   https://google.github.io/styleguide/pyguide.html
+#
+# Its canonical open-source location is:
+#   https://google.github.io/styleguide/pylintrc
+
+[MASTER]
+
+# Files or directories to be skipped. They should be base names, not paths.
+ignore=third_party
+
+# Files or directories matching the regex patterns are skipped. The regex
+# matches against base names, not paths.
+ignore-patterns=
+
+# Pickle collected data for later comparisons.
+persistent=no
+
+# List of plugins (as comma separated values of python modules names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+# Use multiple processes to speed up Pylint.
+jobs=4
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED
+confidence=
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+#enable=
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once).You can also use "--disable=all" to
+# disable everything first and then reenable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use"--disable=all --enable=classes
+# --disable=W"
+disable=abstract-method,
+        apply-builtin,
+        arguments-differ,
+        attribute-defined-outside-init,
+        backtick,
+        bad-option-value,
+        basestring-builtin,
+        buffer-builtin,
+        c-extension-no-member,
+        consider-using-enumerate,
+        cmp-builtin,
+        cmp-method,
+        coerce-builtin,
+        coerce-method,
+        delslice-method,
+        div-method,
+        duplicate-code,
+        eq-without-hash,
+        execfile-builtin,
+        file-builtin,
+        filter-builtin-not-iterating,
+        fixme,
+        getslice-method,
+        global-statement,
+        hex-method,
+        idiv-method,
+        implicit-str-concat-in-sequence,
+        import-error,
+        import-self,
+        import-star-module-level,
+        inconsistent-return-statements,
+        input-builtin,
+        intern-builtin,
+        invalid-str-codec,
+        locally-disabled,
+        long-builtin,
+        long-suffix,
+        map-builtin-not-iterating,
+        misplaced-comparison-constant,
+        missing-function-docstring,
+        metaclass-assignment,
+        next-method-called,
+        next-method-defined,
+        no-absolute-import,
+        no-else-break,
+        no-else-continue,
+        no-else-raise,
+        no-else-return,
+        no-init,  # added
+        no-member,
+        no-name-in-module,
+        no-self-use,
+        nonzero-method,
+        oct-method,
+        old-division,
+        old-ne-operator,
+        old-octal-literal,
+        old-raise-syntax,
+        parameter-unpacking,
+        print-statement,
+        raising-string,
+        range-builtin-not-iterating,
+        raw_input-builtin,
+        rdiv-method,
+        reduce-builtin,
+        relative-import,
+        reload-builtin,
+        round-builtin,
+        setslice-method,
+        signature-differs,
+        standarderror-builtin,
+        suppressed-message,
+        sys-max-int,
+        too-few-public-methods,
+        too-many-ancestors,
+        too-many-arguments,
+        too-many-boolean-expressions,
+        too-many-branches,
+        too-many-instance-attributes,
+        too-many-locals,
+        too-many-nested-blocks,
+        too-many-public-methods,
+        too-many-return-statements,
+        too-many-statements,
+        trailing-newlines,
+        unichr-builtin,
+        unicode-builtin,
+        unidiomatic-typecheck,
+        unnecessary-pass,
+        unpacking-in-except,
+        useless-else-on-loop,
+        useless-object-inheritance,
+        useless-suppression,
+        using-cmp-argument,
+        wrong-import-order,
+        xrange-builtin,
+        zip-builtin-not-iterating,
+
+
+[REPORTS]
+
+# Set the output format. Available formats are text, parseable, colorized, msvs
+# (visual studio) and html. You can also give a reporter class, eg
+# mypackage.mymodule.MyReporterClass.
+output-format=text
+
+# Put messages in a separate file for each module / package specified on the
+# command line instead of printing them on stdout. Reports (if any) will be
+# written in a file name "pylint_global.[txt|html]". This option is deprecated
+# and it will be removed in Pylint 2.0.
+#files-output=no
+
+# Tells whether to display a full report or only the messages
+reports=no
+
+# Python expression which should return a note less than 10 (10 is the highest
+# note). You have access to the variables errors warning, statement which
+# respectively contain the number of errors / warnings messages and the total
+# number of statements analyzed. This is used by the global evaluation report
+# (RP0004).
+evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details
+#msg-template=
+
+
+[BASIC]
+
+# Good variable names which should always be accepted, separated by a comma
+good-names=main,_
+
+# Bad variable names which should always be refused, separated by a comma
+bad-names=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Include a hint for the correct naming format with invalid-name
+include-naming-hint=no
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl
+
+# Regular expression matching correct function names
+function-rgx=^(?:(?P<exempt>setUp|tearDown|setUpModule|tearDownModule)|(?P<camel_case>_?[A-Z][a-zA-Z0-9]*)|(?P<snake_case>_?[a-z][a-z0-9_]*))$
+
+# Regular expression matching correct variable names
+variable-rgx=^[a-z][a-z0-9_]*$
+
+# Regular expression matching correct constant names
+const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$
+
+# Regular expression matching correct attribute names
+attr-rgx=^_{0,2}[a-z][a-z0-9_]*$
+
+# Regular expression matching correct argument names
+argument-rgx=^[a-z][a-z0-9_]*$
+
+# Regular expression matching correct class attribute names
+class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$
+
+# Regular expression matching correct inline iteration names
+inlinevar-rgx=^[a-z][a-z0-9_]*$
+
+# Regular expression matching correct class names
+class-rgx=^_?[A-Z][a-zA-Z0-9]*$
+
+# Regular expression matching correct module names
+module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$
+
+# Regular expression matching correct method names
+method-rgx=(?x)^(?:(?P<exempt>_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P<camel_case>_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P<snake_case>_{0,2}[a-z][a-z0-9_]*))$
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=10
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# mixin class is detected if its name ends with "mixin" (case insensitive).
+ignore-mixin-members=yes
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis. It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+
+[FORMAT]
+
+# Maximum number of characters on a single line.
+#max-line-length=80
+
+# TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt
+# lines made too long by directives to pytype.
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=(?x)(
+  ^\s*(\#\ )?<?https?://\S+>?$|
+  ^\s*(from\s+\S+\s+)?import\s+.+$)
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=yes
+
+# List of optional constructs for which whitespace checking is disabled. `dict-
+# separator` is used to allow tabulation in dicts, etc.: {1  : 1,\n222: 2}.
+# `trailing-comma` allows a space between comma and closing bracket: (a, ).
+# `empty-line` allows space-only lines.
+#no-space-check=
+
+# Maximum number of lines in a module
+max-module-lines=99999
+
+# String used as indentation unit.  The internal Google style guide mandates 2
+# spaces.  Google's externaly-published style guide says 4, consistent with
+# PEP 8.  Here, we use 2 spaces, for conformity with many open-sourced Google
+# projects (like TensorFlow).
+indent-string='  '
+
+# Number of spaces of indent required inside a hanging  or continued line.
+indent-after-paren=4
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=TODO
+
+
+[STRING]
+
+# This flag controls whether inconsistent-quotes generates a warning when the
+# character used as a quote delimiter is used inconsistently within a module.
+check-quote-consistency=yes
+
+
+[VARIABLES]
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# A regular expression matching the name of dummy variables (i.e. expectedly
+# not used).
+dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_)
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid to define new builtins when possible.
+additional-builtins=
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,_cb
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools
+
+
+[LOGGING]
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format
+logging-modules=logging,absl.logging,tensorflow.io.logging
+
+
+[SIMILARITIES]
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+# Ignore comments when computing similarities.
+ignore-comments=yes
+
+# Ignore docstrings when computing similarities.
+ignore-docstrings=yes
+
+# Ignore imports when computing similarities.
+ignore-imports=no
+
+
+[SPELLING]
+
+# Spelling dictionary name. Available dictionaries: none. To make it working
+# install python-enchant package.
+spelling-dict=
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to indicated private dictionary in
+# --spelling-private-dict-file option instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[IMPORTS]
+
+# Deprecated modules which should not be used, separated by a comma
+deprecated-modules=regsub,
+                   TERMIOS,
+                   Bastion,
+                   rexec,
+                   sets
+
+# Create a graph of every (i.e. internal and external) dependencies in the
+# given file (report RP0402 must not be disabled)
+import-graph=
+
+# Create a graph of external dependencies in the given file (report RP0402 must
+# not be disabled)
+ext-import-graph=
+
+# Create a graph of internal dependencies in the given file (report RP0402 must
+# not be disabled)
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant, absl
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+
+[CLASSES]
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+                      __new__,
+                      setUp
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,
+                  _fields,
+                  _replace,
+                  _source,
+                  _make
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls,
+                            class_
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=mcs
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when being caught. Defaults to
+# "Exception"
+overgeneral-exceptions=StandardError,
+                       Exception,
+                       BaseException
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..2cbadf3
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,15 @@
+{
+  "editor.tabSize": 2,
+  "files.trimTrailingWhitespace": true,
+  "cSpell.words": ["iswap", "pyhamilton", "subresource"],
+  "python.linting.pylintEnabled": true,
+  "python.testing.pytestArgs": ["pyhamilton"],
+  "python.testing.unittestEnabled": false,
+  "python.testing.pytestEnabled": true,
+  "python.testing.autoTestDiscoverOnSaveEnabled": true,
+  "editor.rulers": [100],
+  "editor.formatOnSave": true,
+  "files.exclude": {
+    "**/__pycache__": true
+  }
+}
diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 0000000..0a8f2d7
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,4 @@
+build
+_autosummary
+jupyter_execute
+.ipynb_checkpoints
diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst
new file mode 100644
index 0000000..71903d8
--- /dev/null
+++ b/docs/_templates/autosummary/class.rst
@@ -0,0 +1,29 @@
+{{ fullname | escape | underline }}
+
+.. currentmodule:: {{ module }}
+
+.. autoclass:: {{ objname }}
+
+   {% block attributes %}
+   {% if attributes %}
+   .. rubric:: Attributes
+
+   .. autosummary::
+      :toctree: .
+   {% for item in attributes %}
+      ~{{ fullname }}.{{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
+   {% block methods %}
+   {% if methods %}
+   .. rubric:: Methods
+
+   .. autosummary::
+      :toctree: .
+   {% for item in methods %}
+      ~{{ fullname }}.{{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst
new file mode 100644
index 0000000..cf734f6
--- /dev/null
+++ b/docs/_templates/autosummary/module.rst
@@ -0,0 +1,55 @@
+{{ fullname | escape | underline}}
+
+.. currentmodule:: {{ module }}
+
+.. automodule:: {{ fullname }}
+
+   {% block attributes %}
+   {% if attributes %}
+   .. rubric:: Module Attributes
+
+   .. autosummary::
+      :toctree:
+   {% for item in attributes %}
+      {{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
+   {% block functions %}
+   {% if functions %}
+   .. rubric:: {{ _('Functions') }}
+
+   .. autosummary::
+      :toctree:
+   {% for item in functions %}
+      {{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
+   {% block classes %}
+   {% if classes %}
+   .. rubric:: {{ _('Classes') }}
+
+   .. autosummary::
+      :toctree:
+      :template: class.rst
+   {% for item in classes %}
+      {{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
+   {% block exceptions %}
+   {% if exceptions %}
+   .. rubric:: {{ _('Exceptions') }}
+
+   .. autosummary::
+      :toctree:
+   {% for item in exceptions %}
+      {{ item }}
+   {%- endfor %}
+   {% endif %}
+   {% endblock %}
+
diff --git a/docs/basic.ipynb b/docs/basic.ipynb
new file mode 100644
index 0000000..c15df46
--- /dev/null
+++ b/docs/basic.ipynb
@@ -0,0 +1,465 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Basic liquid handling\n",
+    "\n",
+    "In this notebook, you will learn how to use PyHamilton to move water from one range of wells to another.\n",
+    "\n",
+    "**Note: before running this notebook, you should have**:\n",
+    "\n",
+    "- Installed PyHamilton and the USB driver as described in [the installation guide](installation).\n",
+    "- Connected the Hamilton to your computer using the USB cable.\n",
+    "\n",
+    "Video of what this code does:\n",
+    "\n",
+    "<iframe width=\"640\" height=\"360\" src=\"https://www.youtube.com/embed/NN6ltrRj3bU\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture\" allowfullscreen></iframe>"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Setting up a connection with the robot\n",
+    "\n",
+    "Start by importing the {class}`~pyhamilton.liquid_handling.liquid_handler.LiquidHandler` class, which will serve as a front end for all liquid handling operations.\n",
+    "\n",
+    "Backends serve as communicators between between `LiquidHandler`s and the actual hardware. Since we are using a Hamilton STAR, we also import the {class}`~pyhamilton.liquid_handling.backends.STAR` backend."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pyhamilton.liquid_handling import LiquidHandler\n",
+    "from pyhamilton.liquid_handling.backends import STAR"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Create a new liquid handler using `STAR` as its backend"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "backend = STAR()\n",
+    "lh = LiquidHandler(backend=backend)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The final step is to open communication with the robot. This is done using the {func}`~pyhamilton.liquid_handling.LiquidHandler.setup` method."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Finding Hamilton USB device...\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Found Hamilton USB device.\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Found endpoints. \n",
+      "Write:\n",
+      "       ENDPOINT 0x2: Bulk OUT ===============================\n",
+      "       bLength          :    0x7 (7 bytes)\n",
+      "       bDescriptorType  :    0x5 Endpoint\n",
+      "       bEndpointAddress :    0x2 OUT\n",
+      "       bmAttributes     :    0x2 Bulk\n",
+      "       wMaxPacketSize   :   0x40 (64 bytes)\n",
+      "       bInterval        :    0x0 \n",
+      "Read:\n",
+      "       ENDPOINT 0x81: Bulk IN ===============================\n",
+      "       bLength          :    0x7 (7 bytes)\n",
+      "       bDescriptorType  :    0x5 Endpoint\n",
+      "       bEndpointAddress :   0x81 IN\n",
+      "       bmAttributes     :    0x2 Bulk\n",
+      "       wMaxPacketSize   :   0x40 (64 bytes)\n",
+      "       bInterval        :    0x0\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Sent command: C0QWid0004\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Received response: C0QWid0004er00/00qw1\n"
+     ]
+    }
+   ],
+   "source": [
+    "lh.setup()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Creating the deck layout\n",
+    "\n",
+    "Now that we have a `LiquidHandler` instance, we can define the deck layout.\n",
+    "\n",
+    "The layout in this tutorial will contain five sets of standard volume tips with filter, 1 set of 96 1mL wells, and tip and plate carriers on which these resources are positioned.\n",
+    "\n",
+    "Start by importing the relevant objects and variables from the PyHamilton package. This notebook uses the following resources:\n",
+    "\n",
+    "- {class}`~pyhamilton.liquid_handling.resources.ml_star.tip_carriers.TIP_CAR_480_A00` tip carrier\n",
+    "- {class}`~pyhamilton.liquid_handling.resources.ml_star.plate_carriers.PLT_CAR_L5AC_A00` plate carrier\n",
+    "- {class}`~pyhamilton.liquid_handling.resources.corning_costar.plates.Cos_96_DW_1mL` wells\n",
+    "- {class}`~pyhamilton.liquid_handling.resources.ml_star.tips.STF_L` tips"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pyhamilton.liquid_handling.resources import (\n",
+    "    TIP_CAR_480_A00,\n",
+    "    PLT_CAR_L5AC_A00,\n",
+    "    Cos_96_DW_1mL,\n",
+    "    STF_L\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Then create a tip carrier named `tip carrier`, which will contain tips at all 5 positions. These positions can be accessed using `tip_car[x]`, and are 0 indexed."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tip_car = TIP_CAR_480_A00(name='tip carrier')\n",
+    "tip_car[0] = STF_L(name='tips_01')\n",
+    "tip_car[1] = STF_L(name='tips_02')\n",
+    "tip_car[2] = STF_L(name='tips_03')\n",
+    "tip_car[3] = STF_L(name='tips_04')\n",
+    "tip_car[4] = STF_L(name='tips_05')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Use {func}`~pyhamilton.liquid_handling.LiquidHandler.assign_resource` to assign the tip carrier to the liquid handler. All resources contained by this carrier will also be assigned.\n",
+    "\n",
+    "In the `rails` parameter, we can pass the location of the tip carrier. The locations of the tips will automatically be calculated."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.assign_resource(tip_car, rails=3)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Repeat this for the plates."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plt_car = PLT_CAR_L5AC_A00(name='plate carrier')\n",
+    "plt_car[0] = Cos_96_DW_1mL(name='plate_01')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.assign_resource(plt_car, rails=9)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Let's look at a summary of the deck layout using {func}`~pyhamilton.liquid_handling.LiquidHandler.summary`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Rail     Resource                   Type                Coordinates (mm)\n",
+      "===============================================================================================\n",
+      "(3)  ├── tip carrier                TIP_CAR_480_A00     (145.000, 063.000, 100.000)\n",
+      "     │   ├── tips_01                STF_L               (162.900, 145.800, 164.450)\n",
+      "     │   ├── tips_02                STF_L               (162.900, 241.800, 164.450)\n",
+      "     │   ├── tips_03                STF_L               (162.900, 337.800, 164.450)\n",
+      "     │   ├── tips_04                STF_L               (162.900, 433.800, 164.450)\n",
+      "     │   ├── tips_05                STF_L               (162.900, 529.800, 164.450)\n",
+      "     │\n",
+      "(9)  ├── plate carrier              PLT_CAR_L5AC_A00    (280.000, 063.000, 100.000)\n",
+      "     │   ├── plate_01               Cos_96_DW_1mL       (298.000, 146.000, 187.150)\n",
+      "     │   ├── <empty>\n",
+      "     │   ├── <empty>\n",
+      "     │   ├── <empty>\n",
+      "     │   ├── <empty>\n"
+     ]
+    }
+   ],
+   "source": [
+    "lh.summary()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Picking up tips\n",
+    "\n",
+    "Picking up tips is as easy as specifying the name of the tips resource and for each channel the locations of the tips within that resource. Here, the parameter names are included, but that is not required: `h.pickup_tips(\"tips_01\", \"A1\", \"B1\", \"C1\")` would be the same."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Sent command: C0TPid0050xp01629 01629 01629&yp2418 2328 2238 0000&tm1 1 1 0&tt01tp2244tz2164th2450td0\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Received response: C0TPid0050er00/00\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "{'id': 50}"
+      ]
+     },
+     "execution_count": 11,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "lh.pickup_tips(\"tips_01\", channel_1=\"A1\", channel_2=\"B1\", channel_3=\"C1\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Aspirating and dispensing\n",
+    "\n",
+    "PyHamilton has many built-in liquid classes, which are used to update the default parameters of the liquid handling operations. This includes correcting the volume to aspirate or dispense, according to a liquid's properties. Here, we will use the default {class}`~pyhamilton.liquid_handling.liquid_classes.StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol` liquid class."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pyhamilton.liquid_handling.liquid_classes import StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Aspirating and dispensing work similarly to picking up tips: where you use booleans to specify which tips to pick up, with aspiration and dispensing you use floats to specify the volume to aspirate or dispense in $\\mu L$.\n",
+    "\n",
+    "The cells below move liquid from wells `'A1:C1'` to `'D1:F1'` using channels 1, 2, and 3 using the {func}`~pyhamilton.liquid_handling.LiquidHandler.aspirate` and {func}`~pyhamilton.liquid_handling.LiquidHandler.dispense` methods."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Sent command: C0ASid0052at0&tm1 1 1 0&xp02980 02980 02980 00000&yp1460 1370 1280 0000&th2450te2450lp2321 2321 2321&ch000 000 000&zl1881 1881 1881&po0100 0100 0100&zu0032 0032 0032&zr06180 06180 06180&zx1871 1871 1871&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&av01072 00536 02144&as1000 1000 1000&ta000 000 000&ba0000 0000 0000&oa000 000 000&lm0 0 0&ll1 1 1&lv1 1 1&zo000 000 000&ld00 00 00&de0020 0020 0020&wt10 10 10&mv00000 00000 00000&mc00 00 00&mp000 000 000&ms1000 1000 1000&mh0000 0000 0000&gi000 000 000&gj0gk0lk0 0 0&ik0000 0000 0000&sd0500 0500 0500&se0500 0500 0500&sz0300 0300 0300&io0000 0000 0000&il00000 00000 00000&in0000 0000 0000&\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Received response: C0ASid0052er00/00\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "'C0ASid0052er00/00'"
+      ]
+     },
+     "execution_count": 14,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "lh.aspirate(\"plate_01\", (\"A1\", 100.0), (\"B1\", 50.0), (\"C1\", 200.0),\n",
+    "  liquid_class=StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "After the liquid has been aspirated, dispense it in the wells below. Note that while we specify different wells, we are still using the same channels. This is needed because only these channels contain liquid, of course."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 15,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Sent command: C0DSid0053dm2 2 2&tm1 1 1 0&xp02980 02980 02980 00000&yp1190 1100 1010 0000&zx1871 1871 1871&lp2321 2321 2321&zl1881 1881 1881&po0100 0100 0100&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&zu0032 0032 0032&zr06180 06180 06180&th2450te2450dv01072 00536 02144&ds1200 1200 1200&ss0050 0050 0050&rv000 000 000&ta000 000 000&ba0000 0000 0000&lm0 0 0&dj00zo000 000 000&ll1 1 1&lv1 1 1&de0020 0020 0020&wt00 00 00&mv00000 00000 00000&mc00 00 00&mp000 000 000&ms0010 0010 0010&mh0000 0000 0000&gi000 000 000&gj0gk0\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Received response: C0DSid0053er00/00\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "{'id': 53}"
+      ]
+     },
+     "execution_count": 16,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "lh.dispense(\"plate_01\", (\"D1\", 100.0), (\"E1\", 50.0), (\"F1\", 200.0))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Let's move the liquid back to the original wells."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 17,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Sent command: C0ASid0054at0&tm1 1 1 0&xp02980 02980 02980 00000&yp1190 1100 1010 0000&th2450te2450lp2321 2321 2321&ch000 000 000&zl1881 1881 1881&po0100 0100 0100&zu0032 0032 0032&zr06180 06180 06180&zx1871 1871 1871&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&av01072 00536 02144&as1000 1000 1000&ta000 000 000&ba0000 0000 0000&oa000 000 000&lm0 0 0&ll1 1 1&lv1 1 1&zo000 000 000&ld00 00 00&de0020 0020 0020&wt10 10 10&mv00000 00000 00000&mc00 00 00&mp000 000 000&ms1000 1000 1000&mh0000 0000 0000&gi000 000 000&gj0gk0lk0 0 0&ik0000 0000 0000&sd0500 0500 0500&se0500 0500 0500&sz0300 0300 0300&io0000 0000 0000&il00000 00000 00000&in0000 0000 0000&\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Received response: C0ASid0054er00/00\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Sent command: C0DSid0055dm2 2 2&tm1 1 1 0&xp02980 02980 02980 00000&yp1460 1370 1280 0000&zx1871 1871 1871&lp2321 2321 2321&zl1881 1881 1881&po0100 0100 0100&ip0000 0000 0000&it0 0 0&fp0000 0000 0000&zu0032 0032 0032&zr06180 06180 06180&th2450te2450dv01072 00536 02144&ds1200 1200 1200&ss0050 0050 0050&rv000 000 000&ta000 000 000&ba0000 0000 0000&lm0 0 0&dj00zo000 000 000&ll1 1 1&lv1 1 1&de0020 0020 0020&wt00 00 00&mv00000 00000 00000&mc00 00 00&mp000 000 000&ms0010 0010 0010&mh0000 0000 0000&gi000 000 000&gj0gk0\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Received response: C0DSid0055er00/00\n"
+     ]
+    }
+   ],
+   "source": [
+    "lh.aspirate(\"plate_01\", (\"D1\", 100.0), (\"E1\", 50.0), (\"F1\", 200.0))\n",
+    "lh.dispense(\"plate_01\", (\"A1\", 100.0), (\"B1\", 50.0), (\"C1\", 200.0))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Discarding tips\n",
+    "\n",
+    "Finally, you can discard tips by using the {func}`~pyhamilton.liquid_handling.LiquidHandler.discard_tips` method."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 18,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Sent command: C0TRid0056xp01629 01629 01629 00000&yp2418 2328 2238 0000&tm1 1 1 0 0&tt01tp2244tz2164th2450ti0\n",
+      "INFO:pyhamilton.liquid_handling.backends.hamilton:Received response: C0TRid0056er00/00kz381 356 365 000 000 000 000 000vz386 360 369 000 000 000 000 000\n"
+     ]
+    },
+    {
+     "data": {
+      "text/plain": [
+       "'C0TRid0056er00/00kz381 356 365 000 000 000 000 000vz386 360 369 000 000 000 000 000'"
+      ]
+     },
+     "execution_count": 19,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "lh.discard_tips(\"tips_01\", \"A1\", \"B1\", \"C1\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.13"
+  },
+  "vscode": {
+   "interpreter": {
+    "hash": "144ec50570006c78c95fcbf03daf60eaeab01aab98729d454cf125bd413b5e53"
+   }
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..d778dab
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,99 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# This file only contains a selection of the most common options. For a full
+# list see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Path setup --------------------------------------------------------------
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+import sys
+sys.path.insert(0, os.path.abspath('..'))
+
+
+# -- Project information -----------------------------------------------------
+
+project = 'PyHamilton'
+copyright = '2022, PyHamilton'
+author = 'The PyHamilton authors'
+
+
+# -- General configuration ---------------------------------------------------
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = [
+  'sphinx.ext.napoleon',
+  'sphinx.ext.autodoc',
+  'sphinx.ext.autosummary',
+  'sphinx.ext.autosectionlabel',
+  'sphinx.ext.intersphinx',
+  'sphinx.ext.mathjax',
+  'myst_nb',
+  'sphinx_copybutton'
+]
+
+intersphinx_mapping = {
+    'python': ('https://docs.python.org/3/', None),
+}
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This pattern also affects html_static_path and html_extra_path.
+exclude_patterns = [
+  'build/*',
+  '_templates',
+  'Thumbs.db',
+  '.DS_Store',
+  'jupyter_execute'
+]
+
+default_role = 'code' # allow single backticks for inline code
+
+# -- Options for HTML output -------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages.  See the documentation for
+# a list of builtin themes.
+#
+html_theme = 'sphinx_book_theme'
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+html_theme_options = {
+  'repository_url': 'https://github.com/pyhamilton/pyhamilton',
+  'use_repository_button': False,
+  'use_edit_page_button': True,
+  'repository_branch': 'usb',
+  'path_to_docs': 'docs',
+  'use_issues_button': False,
+}
+
+
+autodoc_default_flags = ['members']
+autosummary_generate = True
+autosummary_ignore_module_all = False
+autosectionlabel_prefix_document = True
+always_document_param_types = True
+
+napoleon_attr_annotations = True
+napoleon_google_docstring = True
+napoleon_preprocess_types = True
+autodoc_typehints = 'both'
+napoleon_use_rtype = False
+napoleon_use_ivar = True
+
+nb_execution_mode = 'off'
+myst_enable_extensions = ['dollarmath']
+
+source_suffix = ['.rst', '.md']
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..0e75bbd
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,3 @@
+# Developer Guide
+
+TODO
diff --git a/docs/img/simulator/after_lh.png b/docs/img/simulator/after_lh.png
new file mode 100644
index 0000000..46c01e4
Binary files /dev/null and b/docs/img/simulator/after_lh.png differ
diff --git a/docs/img/simulator/assignment.png b/docs/img/simulator/assignment.png
new file mode 100644
index 0000000..b301219
Binary files /dev/null and b/docs/img/simulator/assignment.png differ
diff --git a/docs/img/simulator/empty.png b/docs/img/simulator/empty.png
new file mode 100644
index 0000000..518ecf2
Binary files /dev/null and b/docs/img/simulator/empty.png differ
diff --git a/docs/img/simulator/resources.png b/docs/img/simulator/resources.png
new file mode 100644
index 0000000..ef52bd1
Binary files /dev/null and b/docs/img/simulator/resources.png differ
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..ff7105b
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,42 @@
+Welcome to PyHamilton's documentation!
+======================================
+
+PyHamilton is an OS agnostic, pure Python interface to Hamilton liquid handling robots.
+
+.. warning::
+  This documentation covers
+  `the experimental usb branch <https://github.com/pyhamilton/pyhamilton/tree/usb>`_ and should not
+  be used in production. See `this GitHub repo <https://github.com/dgretton/pyhamilton>`_ for the
+  current stable version.
+
+.. toctree::
+   :maxdepth: 1
+   :caption: Getting Started
+
+   installation.md
+   development.md
+
+
+.. toctree::
+   :maxdepth: 1
+   :caption: Liquid handling
+
+   basic
+   using-the-simulator
+
+
+.. toctree::
+   :maxdepth: 4
+   :caption: API documentation
+
+   pyhamilton
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
+
+
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..10ecc37
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,56 @@
+# Installation
+
+While PyHamilton is written in pure Python, you will probably need to replace the Hamilton driver
+with a USB driver that is compatible with [PyUSB](https://github.com/pyusb/pyusb). These
+instructions describe how to install PyHamilton using pip, and how to install a PyUSB driver.
+
+## Installing PyHamilton
+
+It is recommended that you use a virtual environment to install PyHamilton. See the
+[virtualenvironment](https://virtualenv.pypa.io/en/latest/) documentation for more information.
+
+```bash
+mkdir your_project
+cd your_project
+python -m virtualenv env
+source env/bin/activate
+```
+
+### Using pip
+
+The following will install PyHamilton and the essential dependencies:
+
+```bash
+pip install pyhamilton
+```
+
+If you want to build documentation or run tests, you need install the additional dependencies, also
+using pip:
+
+```bash
+pip install pyhamilton[docs]
+pip install pyhamilton[tests]
+```
+
+Or install all dependencies at once:
+
+```bash
+pip install pyhamilton[all]
+```
+
+### From source
+
+```bash
+git clone https://github.com/pyhamilton/pyhamilton.git
+cd pyhamilton
+pip install -e .[all]
+```
+
+See [the developer guide](development) for specific instructions on testing, documentation and
+development.
+
+## Updating the USB driver
+
+[Zadig](https://zadig.akeo.ie)
+
+TODO
diff --git a/docs/pyhamilton.liquid_handling.backends.rst b/docs/pyhamilton.liquid_handling.backends.rst
new file mode 100644
index 0000000..28ed534
--- /dev/null
+++ b/docs/pyhamilton.liquid_handling.backends.rst
@@ -0,0 +1,40 @@
+﻿.. currentmodule:: pyhamilton.liquid_handling
+
+pyhamilton.liquid_handling.backends package
+===========================================
+
+Backends are used to communicate with liquid handling devices on a low level. This can be useful
+when you want to have very low level control over the liquid handling device or want to use a
+feature that is not yet implemented in the front end.
+
+Abstract
+--------
+
+.. autosummary::
+  :toctree: _autosummary
+  :nosignatures:
+  :recursive:
+
+    pyhamilton.liquid_handling.backends.LiquidHandlerBackend
+
+Hardware
+--------
+
+.. autosummary::
+  :toctree: _autosummary
+  :nosignatures:
+  :recursive:
+
+    pyhamilton.liquid_handling.backends.hamilton.HamiltonLiquidHandler
+    pyhamilton.liquid_handling.backends.hamilton.STAR
+
+
+Simulator
+---------
+
+.. autosummary::
+  :toctree: _autosummary
+  :nosignatures:
+  :recursive:
+
+    pyhamilton.liquid_handling.backends.simulation.SimulationBackend
diff --git a/docs/pyhamilton.liquid_handling.liquid_classes.rst b/docs/pyhamilton.liquid_handling.liquid_classes.rst
new file mode 100644
index 0000000..42cee36
--- /dev/null
+++ b/docs/pyhamilton.liquid_handling.liquid_classes.rst
@@ -0,0 +1,19 @@
+﻿.. currentmodule:: pyhamilton.liquid_handling
+
+pyhamilton.liquid_handling.liquid_classes package
+=================================================
+
+
+Liquid classes, are used to update the default parameters of the liquid handling operations. This
+includes correcting the volume to aspirate or dispense, according to a liquid's properties.
+
+Abstract
+--------
+
+.. autoclass:: pyhamilton.liquid_handling.liquid_classes.LiquidClass
+
+Liquid classes
+--------------
+
+.. automodule:: pyhamilton.liquid_handling.liquid_classes
+  :members:
diff --git a/docs/pyhamilton.liquid_handling.resources.rst b/docs/pyhamilton.liquid_handling.resources.rst
new file mode 100644
index 0000000..004cf60
--- /dev/null
+++ b/docs/pyhamilton.liquid_handling.resources.rst
@@ -0,0 +1,53 @@
+﻿.. currentmodule:: pyhamilton.liquid_handling
+
+pyhamilton.liquid_handling.resources package
+============================================
+
+Resources represent on-deck liquid handling equipment, including plate and plate carriers and tips
+and carriers. Many resources defined in VENUS are also defined in this package. In addition,
+using the abstract base classes defined in :ref:`pyhamilton.liquid_handling.resources.abstract <pyhamilton.liquid_handling.resources:abstract>`,
+you can define your own resources.
+
+Abstract
+--------
+
+.. autosummary::
+  :toctree: _autosummary
+  :nosignatures:
+  :recursive:
+
+    pyhamilton.liquid_handling.resources.abstract
+    pyhamilton.liquid_handling.resources.abstract.Coordinate
+    pyhamilton.liquid_handling.resources.abstract.Resource
+    pyhamilton.liquid_handling.resources.abstract.Tips
+    pyhamilton.liquid_handling.resources.abstract.TipType
+    pyhamilton.liquid_handling.resources.abstract.Plate
+    pyhamilton.liquid_handling.resources.abstract.Carrier
+    pyhamilton.liquid_handling.resources.abstract.TipCarrier
+    pyhamilton.liquid_handling.resources.abstract.PlateCarrier
+
+
+ML Star resources
+-----------------
+
+.. autosummary::
+  :toctree: _autosummary
+  :nosignatures:
+  :recursive:
+
+    pyhamilton.liquid_handling.resources.ml_star
+    pyhamilton.liquid_handling.resources.ml_star.tip_types
+    pyhamilton.liquid_handling.resources.ml_star.tips
+    pyhamilton.liquid_handling.resources.ml_star.tip_carriers
+    pyhamilton.liquid_handling.resources.ml_star.plate_carriers
+
+
+Corning Costar
+--------------
+
+.. autosummary::
+  :toctree: _autosummary
+  :nosignatures:
+  :recursive:
+
+    pyhamilton.liquid_handling.resources.corning_costar.plates
diff --git a/docs/pyhamilton.liquid_handling.rst b/docs/pyhamilton.liquid_handling.rst
new file mode 100644
index 0000000..a8ca823
--- /dev/null
+++ b/docs/pyhamilton.liquid_handling.rst
@@ -0,0 +1,53 @@
+﻿.. currentmodule:: pyhamilton.liquid_handling
+
+pyhamilton.liquid_handling package
+==================================
+
+This package contains all APIs relevant to liquid handling.
+See :ref:`Basic liquid handling <Basic:Basic liquid handling>` for a simple example.
+
+Machine control is split into two parts: backends and front ends. Backends are used to control the
+machine, and front ends are used to interact with the backend. Front ends are designed to be
+largely backend agnostic, and can be used with any backend, meaning programs using this API can
+be run on practically all supported hardware.
+
+.. autosummary::
+  :toctree: _autosummary
+  :nosignatures:
+  :recursive:
+
+    pyhamilton.liquid_handling.LiquidHandler
+    pyhamilton.liquid_handling.liquid_handler.AspirationInfo
+    pyhamilton.liquid_handling.liquid_handler.DispenseInfo
+
+
+Backends
+--------
+
+.. toctree::
+  :maxdepth: 3
+
+  pyhamilton.liquid_handling.backends
+
+
+Resources
+---------
+
+The subpackage :code:`resources` contains all resources used by the liquid handling package. You
+can use these to define deck layouts. Many of VENUS' resources are already implemented. If the
+resource is not implemented, you can implement it yourself by subclassing the appropriate class
+in :ref:`pyhamilton.liquid_handling.resources:abstract`.
+
+.. toctree::
+  :maxdepth: 2
+
+  pyhamilton.liquid_handling.resources
+
+
+Liquid classes
+--------------
+
+.. toctree::
+  :maxdepth: 2
+
+  pyhamilton.liquid_handling.liquid_classes
diff --git a/docs/pyhamilton.rst b/docs/pyhamilton.rst
new file mode 100644
index 0000000..562b8fd
--- /dev/null
+++ b/docs/pyhamilton.rst
@@ -0,0 +1,13 @@
+.. currentmodule:: pyhamilton
+
+Public API: pyhamilton package
+==============================
+
+Subpackages
+-----------
+
+.. toctree::
+    :maxdepth: 1
+
+    pyhamilton.liquid_handling
+    pyhamilton.utils
diff --git a/docs/pyhamilton.utils.rst b/docs/pyhamilton.utils.rst
new file mode 100644
index 0000000..58d85c8
--- /dev/null
+++ b/docs/pyhamilton.utils.rst
@@ -0,0 +1,17 @@
+.. currentmodule:: pyhamilton.utils
+
+pyhamilton.utils package
+========================
+
+Various utilities for the pyhamilton package.
+
+.. autosummary::
+  :toctree: _autosummary
+  :nosignatures:
+  :recursive:
+
+    file_parsing
+    list
+    numbers
+    positions
+    string
diff --git a/docs/using-the-simulator.ipynb b/docs/using-the-simulator.ipynb
new file mode 100644
index 0000000..e99c8eb
--- /dev/null
+++ b/docs/using-the-simulator.ipynb
@@ -0,0 +1,602 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "c3c71913",
+   "metadata": {},
+   "source": [
+    "# Using the simulator\n",
+    "\n",
+    "In this notebook you will learn how to use the simulator to test out methods."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d6cdca52",
+   "metadata": {},
+   "source": [
+    "## Setting up a connection with the robot\n",
+    "\n",
+    "As described in the [basic liquid handling tutorial](basic), we will use the {class}`~pyhamilton.liquid_handling.liquid_handler.LiquidHandler` class to control the robot. This time, however, instead of using the Hamilton {class}`~pyhamilton.liquid_handling.backends.hamilton.STAR` backend, we are using virtual the {class}`~pyhamilton.liquid_handling.backends.simulation.simulation.SimulationBackend` backend. This means that liquid handling will work exactly the same, but the commands are sent to the simulator instead of a real physical robot."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "7c5a2629",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pyhamilton.liquid_handling import LiquidHandler\n",
+    "from pyhamilton.liquid_handling.backends.simulation.simulation import SimulationBackend"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "id": "2e280caa",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pyhamilton.liquid_handling.resources import (\n",
+    "    TIP_CAR_480_A00,\n",
+    "    PLT_CAR_L5AC_A00,\n",
+    "    Cos_96_DW_1mL,\n",
+    "    STF_L\n",
+    ")\n",
+    "\n",
+    "sb = SimulationBackend()\n",
+    "lh = LiquidHandler(backend=sb)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "30dcf4a1",
+   "metadata": {},
+   "source": [
+    "Calling {func}`~pyhamilton.liquid_handling.backends.simulation.SimulationBackend.setup` will set up the simulation server and open it in a new browser tab."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "1419f2b3",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "INFO:websockets.server:server listening on 127.0.0.1:2121\n",
+      "INFO:pyhamilton.liquid_handling.backends.simulation.simulation:Simulation server started at http://127.0.0.1:2121\n",
+      "INFO:pyhamilton.liquid_handling.backends.simulation.simulation:File server started at http://127.0.0.1:1337\n"
+     ]
+    }
+   ],
+   "source": [
+    "lh.setup()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "871184ce",
+   "metadata": {},
+   "source": [
+    "For the optimal experience, we recommend that you run the notebook and simulator side by side."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "3002429b",
+   "metadata": {},
+   "source": [
+    "![The empty simulator](./img/simulator/empty.png)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "050ccefd",
+   "metadata": {},
+   "source": [
+    "## Assigning plates and tips\n",
+    "\n",
+    "With the simulator, {func}`~assigning resources <pyhamilton.liquid_handling.LiquidHandler.assign_resource>` has the additional affect of placing the resources on the simulated deck. They will appear immediately."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "23c07ba6",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tip_car = TIP_CAR_480_A00(name='tip carrier')\n",
+    "for i in range(2):\n",
+    "    tip_car[i] = STF_L(name=f'tip_{i}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "cafa85b6",
+   "metadata": {
+    "scrolled": true
+   },
+   "outputs": [],
+   "source": [
+    "lh.assign_resource(tip_car, rails=1, replace=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "27c1bc2e",
+   "metadata": {},
+   "source": [
+    "When you set or delete subresources in a {class}`~pyhamilton.liquid_handling.resources.abstract.Carrier`, that will immediately be reflected in the UI."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "628d9133",
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [],
+   "source": [
+    "tip_car[2] = STF_L(name=f'tip_{2}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "b8eedee6",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tip_car[3] = STF_L(name=f'tip_{3}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "935a2c25",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "del tip_car[3]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "id": "b753686a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tip_car[4] = STF_L(name=f'tip{4}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "id": "fdf9441f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tip_car[4] = None"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "id": "00c1c929",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tip_car[3] = None"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "id": "953f2da1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tip_car[3] = STF_L(name=f'tip_{3}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "id": "0c842523",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tip_car[4] = STF_L(name=f'tip_{4}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 14,
+   "id": "727936f3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plt_car = PLT_CAR_L5AC_A00(name='pre')\n",
+    "for i in range(5):\n",
+    "    plt_car[i] = Cos_96_DW_1mL(name=f'plate_{i}')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 15,
+   "id": "b676a360",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.assign_resource(plt_car, rails=9, replace=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "21835f31",
+   "metadata": {},
+   "source": [
+    "![The simulator after the resources have been assigned](./img/simulator/assignment.png)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "68a9721b",
+   "metadata": {},
+   "source": [
+    "## Build the deck layout: placing resources\n",
+    "\n",
+    "Where you would manually place the resources like tips and liquid on the deck when using a physical system, with the Simulator you can add them using code."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "daf99520",
+   "metadata": {},
+   "source": [
+    "### Tips\n",
+    "\n",
+    "Let's {func}`~pyhamilton.liquid_handling.backends.simulation.SimulationBackend.fill_tips` the tip containers at locations `0` and `1`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 16,
+   "id": "ca3152d0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sb.fill_tips(tip_car[0])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 17,
+   "id": "346c8e43",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sb.fill_tips(tip_car[1])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "225196f2",
+   "metadata": {},
+   "source": [
+    "When you no longer need tips, use {func}`~pyhamilton.liquid_handling.backends.simulation.SimulationBackend.remove_tips` to remove them."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 18,
+   "id": "8f574b8d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sb.fill_tips(tip_car[4])\n",
+    "sb.remove_tips(tip_car[4], pattern=[[True]*6 + [False]*6]*8)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 19,
+   "id": "8e1ed1e6",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sb.place_tips(tip_car[3], pattern=[[True, False]*6]*8)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 20,
+   "id": "579da35b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sb.place_tips(tip_car[2], pattern=[[True, True, False, False]*3]*8)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "594d8016",
+   "metadata": {},
+   "source": [
+    "### Liquids\n",
+    "\n",
+    "Adding liquid to wells works similarly. You can use {func}`~pyhamilton.liquid_handling.backends.simulation.SimulationBackend.adjust_well_volume` to adjust the volume of individual wells in each resource. Note that the opacity of the well matches the volume of the well."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 21,
+   "id": "3cf87c52",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sb.adjust_well_volume(plt_car[2], pattern=[[100, 800]*6]*8)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 22,
+   "id": "5b76aac2",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sb.adjust_well_volume(plt_car[1], pattern=[[1000]*12]*8)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 23,
+   "id": "205e2625",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sb.adjust_well_volume(plt_car[0], pattern=[[0]*12]*8)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "66a27cfc",
+   "metadata": {},
+   "source": [
+    "![Simulator after the tips have been placed and the volumes adjusted](./img/simulator/resources.png)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "27af93d8",
+   "metadata": {},
+   "source": [
+    "## Liquid handling\n",
+    "\n",
+    "Once the layout is complete, you can run the same commands as described in the [basic liquid handling tutorial](basic)."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "21c3f2cd",
+   "metadata": {},
+   "source": [
+    "### Picking up tips"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 24,
+   "id": "f97eadd4",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.pickup_tips('tip_0', \"A1\", \"B2\", \"C3\", \"D4\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 25,
+   "id": "bf46e476",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.discard_tips('tip_0', \"A1\", \"B2\", \"C3\", \"D4\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "5c6948b2",
+   "metadata": {},
+   "source": [
+    "### Aspirating and dispensing"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 26,
+   "id": "947977c7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.pickup_tips('tip_0', \"A1\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 27,
+   "id": "c25a147f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.aspirate(plt_car[2], (\"A2\", 300))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 28,
+   "id": "e86428ea",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.dispense(plt_car[4], (\"A2\", 300))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 29,
+   "id": "70117e04",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.discard_tips('tip_0', \"A1\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "8a8b3d59",
+   "metadata": {},
+   "source": [
+    "### Aspirating using CoRe 96\n",
+    "\n",
+    "The CoRe 96 head supports liquid handling operations for 96 channels at once. Here's how to use:\n",
+    "\n",
+    "- {func}`~pyhamilton.liquid_handling.LiquidHandler.pickup_tips96` for picking up 96 tips;\n",
+    "- {func}`~pyhamilton.liquid_handling.LiquidHandler.aspirate96` for aspirating liquid from the deck, where the `pattern` array controls which wells are aspirated from;\n",
+    "- {func}`~pyhamilton.liquid_handling.LiquidHandler.dispense96` for dispensing liquid to the deck, where the `pattern` array controls which wells are dispensed to, and;\n",
+    "- {func}`~pyhamilton.liquid_handling.LiquidHandler.discard_tips96` for discarding tips to the resource.\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 30,
+   "id": "b8c5706d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.pickup_tips96(tip_car[0])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 31,
+   "id": "c09144c9",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.aspirate96(plt_car[1], [[True] * 12] * 8, volume=400)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 32,
+   "id": "2ba711bb",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.dispense96(plt_car[0], [[True] * 12] * 8, volume=400)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 33,
+   "id": "59d3dae1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.aspirate96(plt_car[1], \"A1:H12\", volume=400)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 34,
+   "id": "f506c35c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.dispense96(plt_car[0], \"A1:H12\", volume=400)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 35,
+   "id": "6d205ea7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh.discard_tips96(tip_car[0])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9cc77505",
+   "metadata": {},
+   "source": [
+    "![The simulator after the liquid handling operations completed](./img/simulator/after_lh.png)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e49020a5",
+   "metadata": {},
+   "source": [
+    "## Shutting down\n",
+    "\n",
+    "When you're done, remember to shut down the simulator by calling {func}`~pyhamilton.liquid_handling.LiquidHandler.stop`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 39,
+   "id": "44a61431",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "INFO:websockets.server:server closing\n",
+      "INFO:websockets.server:connection closed\n"
+     ]
+    }
+   ],
+   "source": [
+    "lh.stop()"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.9.13"
+  },
+  "vscode": {
+   "interpreter": {
+    "hash": "144ec50570006c78c95fcbf03daf60eaeab01aab98729d454cf125bd413b5e53"
+   }
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pyhamilton/__init__.py b/pyhamilton/__init__.py
index ce87701..8a1f36d 100644
--- a/pyhamilton/__init__.py
+++ b/pyhamilton/__init__.py
@@ -1,60 +1,50 @@
 """
 Pyhamilton
 """
+
 import os
-import shutil
-from os.path import dirname, join, abspath
+from os.path import dirname, abspath
 PACKAGE_PATH = abspath(dirname(__file__))
-LAY_BACKUP_DIR = join(PACKAGE_PATH, 'LAY-BACKUP')
-if not os.path.exists(LAY_BACKUP_DIR):
-    os.mkdir(LAY_BACKUP_DIR)
-OEM_STAR_PATH = join(PACKAGE_PATH, 'STAR-OEM')
-if not (os.path.exists(OEM_STAR_PATH)
-		and os.path.exists(os.path.join(OEM_STAR_PATH, 'RunHSLExecutor.dll'))
-		and os.path.exists(os.path.join(OEM_STAR_PATH, 'HSLHttp.dll'))):
-    raise FileNotFoundError('pyhamilton requires .../site-packages/pyhamilton/STAR-OEM, distributed separately.')
-OEM_LAY_PATH = join(OEM_STAR_PATH, 'VENUS_Method', 'STAR_OEM_Test.lay')
-OEM_HSL_PATH = join(OEM_STAR_PATH, 'VENUS_Method', 'STAR_OEM_noFan.hsl')
-OEM_RUN_EXE_PATH = 'C:\\Program Files (x86)\\HAMILTON\\Bin\\HxRun.exe'
-from .interface import *
-from .deckresource import *
-from .oemerr import *
-
-from .utils import *
-
-#from autoconfig import pyhamiltonconfig
+
+# for legacy reasons, we import all venus methods
+from pyhamilton.liquid_handling.backends.hamilton.VENUS.interface import *
+from pyhamilton.liquid_handling.backends.hamilton.VENUS.deckresource import *
+from pyhamilton.liquid_handling.backends.hamilton.VENUS.oemerr import *
+from pyhamilton.liquid_handling.backends.hamilton.VENUS.venus_utils import *
 
 this_file_dir = os.path.dirname(os.path.abspath(__file__))
 PACKAGE_DIR = os.path.abspath(os.path.join(this_file_dir))
 LIBRARY_DIR = os.path.join(PACKAGE_DIR, 'library')
 exe_http = os.path.join(PACKAGE_DIR, 'bin', 'Hamilton HSLHttp Library Installer Version 2.7.exe')
 exe_json = os.path.join(PACKAGE_DIR, 'bin', 'HSLJson Library v1.6 Installer.exe')
-
+LIBRARY_DIR = os.path.join(PACKAGE_DIR, 'library')
 
 def full_paths_list(directory_abs_path):
-    list_files = os.listdir(directory_abs_path)
-    list_file_paths = [directory_abs_path + '\\' + file for file in list_files]
-    return list_file_paths
-
+  list_files = os.listdir(directory_abs_path)
+  list_file_paths = [directory_abs_path + '\\' + file for file in list_files]
+  return list_file_paths
 
 def autoconfig():
-    print("Automatically configuring your PyHamilton installation")
-    os.startfile(exe_http)
-    os.startfile(exe_json)
-    
-    hamilton_lib_dir = os.path.abspath('C:/Program Files (x86)/HAMILTON/Library')
-
-    
-    def recursive_copy(source_dir, target_dir):
-        source_list = full_paths_list(source_dir)
-        for file in source_list:
-            if os.path.isfile(file):
-                shutil.copy(file, target_dir + '//' + os.path.basename(file))
-            if os.path.isdir(file):
-                target = target_dir + '//' + os.path.basename(file)
-                if not os.path.exists(target):
-                    os.mkdir(target)
-                recursive_copy(source_dir + '//' + os.path.basename(file), target)            
-    
-    recursive_copy(LIBRARY_DIR, hamilton_lib_dir)        
-    print("Configuration completed")
+  print("Automatically configuring your PyHamilton installation")
+  os.startfile(exe_http)
+  os.startfile(exe_json)
+
+  hamilton_lib_dir = os.path.abspath('C:/Program Files (x86)/HAMILTON/Library')
+
+  def recursive_copy(source_dir, target_dir):
+    source_list = full_paths_list(source_dir)
+    for file in source_list:
+      if os.path.isfile(file):
+        shutil.copy(file, target_dir + '//' + os.path.basename(file))
+      if os.path.isdir(file):
+        target = target_dir + '//' + os.path.basename(file)
+        if not os.path.exists(target):
+          os.mkdir(target)
+        recursive_copy(source_dir + '//' + os.path.basename(file), target)
+
+  recursive_copy(LIBRARY_DIR, hamilton_lib_dir)
+  print("Configuration completed")
+
+
+import logging
+logging.basicConfig(level=logging.INFO)
diff --git a/pyhamilton/liquid_handling/__init__.py b/pyhamilton/liquid_handling/__init__.py
new file mode 100644
index 0000000..73bac28
--- /dev/null
+++ b/pyhamilton/liquid_handling/__init__.py
@@ -0,0 +1,2 @@
+from .backends import LiquidHandlerBackend, STAR
+from .liquid_handler import LiquidHandler
diff --git a/pyhamilton/liquid_handling/backends/__init__.py b/pyhamilton/liquid_handling/backends/__init__.py
new file mode 100644
index 0000000..fd85a2d
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/__init__.py
@@ -0,0 +1,4 @@
+from .backend import LiquidHandlerBackend
+from .hamilton.errors import HamiltonError
+from .hamilton import STAR
+from .simulation import SimulationBackend
diff --git a/pyhamilton/liquid_handling/backends/backend.py b/pyhamilton/liquid_handling/backends/backend.py
new file mode 100644
index 0000000..84e9543
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/backend.py
@@ -0,0 +1,153 @@
+from abc import ABCMeta, abstractmethod
+import typing
+
+from pyhamilton.liquid_handling.resources import Resource
+
+
+class AspirationInfo: # TODO: real import
+  pass
+class DispenseInfo:
+  pass
+
+
+class LiquidHandlerBackend(object, metaclass=ABCMeta):
+  """
+  Abstract base class for liquid handling robot backends.
+
+  For more information on some methods and arguments, see the documentation for the
+  :class:`~LiquidHandler` class.
+
+  Attributes:
+    setup_finished: Whether the backend has been set up.
+  """
+
+  @abstractmethod
+  def __init__(self):
+    self.setup_finished = False
+
+  @abstractmethod
+  def setup(self):
+    self.setup_finished = True
+
+  @abstractmethod
+  def stop(self):
+    self.setup_finished = False
+
+  def __enter__(self):
+    self.setup()
+    return self
+
+  def __exit__(self, *exc):
+    self.stop()
+    return False
+
+  def assigned_resource_callback(self, resource: Resource):
+    """ Called when a new resource was assigned to the robot.
+
+    This callback will also be called immediately after the setup method has been called for any
+    resources that were assigned to the robot before it was set up. Note that this callback
+    will also be called for resources that were assigned but not unassigned to the robot before it
+    was set up, but in those cases `unassigned_resource_callback` will be also be called.
+
+    Args:
+      resource: The resource that was assigned to the robot.
+    """
+
+    pass
+
+  def unassigned_resource_callback(self, name: str):
+    """ Called when a resource is unassigned from the robot.
+
+    Args:
+      resource: The name of the resource that was unassigned from the robot.
+    """
+
+    pass
+
+  @abstractmethod
+  def pickup_tips(
+    self,
+    resource,
+    channel_1: typing.Optional[str] = None,
+    channel_2: typing.Optional[str] = None,
+    channel_3: typing.Optional[str] = None,
+    channel_4: typing.Optional[str] = None,
+    channel_5: typing.Optional[str] = None,
+    channel_6: typing.Optional[str] = None,
+    channel_7: typing.Optional[str] = None,
+    channel_8: typing.Optional[str] = None,
+    **backend_kwargs
+  ):
+    """ Pick up tips from the specified resource. """
+    pass
+
+  @abstractmethod
+  def discard_tips(
+    self,
+    resource,
+    channel_1: typing.Optional[str] = None,
+    channel_2: typing.Optional[str] = None,
+    channel_3: typing.Optional[str] = None,
+    channel_4: typing.Optional[str] = None,
+    channel_5: typing.Optional[str] = None,
+    channel_6: typing.Optional[str] = None,
+    channel_7: typing.Optional[str] = None,
+    channel_8: typing.Optional[str] = None,
+    **backend_kwars
+  ):
+    """ Discard tips from the specified resource. """
+    pass
+
+  @abstractmethod
+  def aspirate(
+    self,
+    resource: typing.Union[str, Resource],
+    channel_1: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_2: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_3: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_4: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_5: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_6: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_7: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_8: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    **backend_kwargs
+  ):
+    """ Aspirate liquid from the specified resource using pip. """
+    pass
+
+  @abstractmethod
+  def dispense(
+    self,
+    resource: typing.Union[str, Resource],
+    channel_1: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_2: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_3: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_4: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_5: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_6: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_7: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_8: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    **backend_kwargs
+  ):
+    """ Dispense liquid from the specified resource using pip. """
+    pass
+
+  @abstractmethod
+  def pickup_tips96(self, resource, **backend_kwargs):
+    """ Pick up tips from the specified resource using CoRe 96. """
+    pass
+
+  @abstractmethod
+  def discard_tips96(self, resource, **backend_kwargs):
+    """ Discard tips to the specified resource using CoRe 96. """
+    pass
+
+  @abstractmethod
+  def aspirate96(self, resource, pattern, volume, **backend_kwargs):
+    """ Aspirate liquid from the specified resource using CoRe 96. """
+    pass
+
+  @abstractmethod
+  def dispense96(self, resource, pattern, volume, **backend_kwargs):
+    """ Dispense liquid to the specified resource using CoRe 96. """
+    pass
diff --git a/pyhamilton/liquid_handling/backends/hamilton/STAR.py b/pyhamilton/liquid_handling/backends/hamilton/STAR.py
new file mode 100644
index 0000000..773f0f7
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/hamilton/STAR.py
@@ -0,0 +1,4088 @@
+"""
+This file defines interfaces for all supported Hamilton liquid handling robots.
+"""
+# pylint: disable=invalid-sequence-index
+
+from abc import ABCMeta, abstractmethod
+import datetime
+import enum
+import itertools
+import logging
+import re
+import time
+import typing
+
+from pyhamilton.liquid_handling.resources import (
+  Resource,
+  Plate,
+  Tips,
+  TipType
+)
+from pyhamilton.liquid_handling.backends import LiquidHandlerBackend
+from pyhamilton import utils
+
+from .errors import (
+  HamiltonFirmwareError
+)
+
+logger = logging.getLogger(__name__)
+logging.basicConfig()
+logger.setLevel(logging.INFO)
+
+try:
+  import usb.core
+  import usb.util
+  USE_USB = True
+except ImportError:
+  logger.warn("Could not import pyusb, Hamilton interface will not be available.")
+  USE_USB = False
+
+class HamiltonLiquidHandler(LiquidHandlerBackend, metaclass=ABCMeta):
+  """
+  Abstract base class for Hamilton liquid handling robot backends.
+  """
+
+  @abstractmethod
+  def __init__(self, read_poll_interval=0.005):
+    """
+
+    Args:
+      read_poll_interval: The sleep after each check for device responses, in seconds.
+    """
+
+    super().__init__()
+
+    self.read_poll_interval = read_poll_interval
+    self.id_ = 0
+
+  def _generate_id(self):
+    """ continuously generate unique ids 0 <= x < 10000. """
+    self.id_ += 1
+    return f"{self.id_ % 10000:04}"
+
+  def _assemble_command(self, module, command, **kwargs) -> str:
+    """ Assemble a firmware command to the Hamilton machine.
+
+    Args:
+      module: 2 character module identifier (C0 for master, ...)
+      command: 2 character command identifier (QM for request status, ...)
+      kwargs: any named parameters. the parameter name should also be
+              2 characters long. The value can be any size.
+
+    Returns:
+      A string containing the assembled command.
+    """
+
+    # pylint: disable=redefined-builtin
+
+    cmd = module + command
+    id = self._generate_id()
+    cmd += f"id{id}" # has to be first param
+
+    for k, v in kwargs.items():
+      if type(v) is datetime.datetime:
+        v = v.strftime("%Y-%m-%d %h:%M")
+      elif type(v) is bool:
+        v = 1 if v else 0
+      elif type(v) is list:
+        if type(v[0]) is bool: # convert bool list to int list
+          v = [int(x) for x in v]
+        v = " ".join([str(e) for e in v]) + ("&" if len(v) < 8 else "")
+      if k.endswith("_"): # workaround for kwargs named in, as, ...
+        k = k[:-1]
+      cmd += f"{k}{v}"
+
+    return cmd, id
+
+  def _read_packet(self) -> typing.Optional[str]:
+    """ Read a packet from the Hamilton machine.
+
+    Returns:
+      A string containing the decoded packet, or None if no packet was received.
+    """
+
+    try:
+      res = self.dev.read(
+        self.read_endpoint,
+        self.read_endpoint.wMaxPacketSize
+      )
+    except usb.core.USBError:
+      # No data available (yet), this will give a timeout error. Don't reraise.
+      return None
+    if res is not None:
+      res = bytearray(res).decode("utf-8") # convert res into text
+      return res
+    return None
+
+  def parse_fw_string(self, resp: str, fmt: str = "") -> typing.Optional[dict]:
+    """ Parse a machine command or response string according to a format string.
+
+    The format contains names of parameters (always length 2),
+    followed by an arbitrary number of the following, but always
+    the same:
+    - '&': char
+    - '#': decimal
+    - '*': hex
+
+    The order of parameters in the format and response string do not
+    have to (and often do not) match.
+
+    The identifier parameter (id####) is added automatically.
+
+    TODO: string parsing
+    The firmware docs mention strings in the following format: '...'
+    However, the length of these is always known (except when reading
+    barcodes), so it is easier to convert strings to the right number
+    of '&'. With barcode reading the length of the barcode is included
+    with the response string. We'll probably do a custom implementation
+    for that.
+
+    TODO: spaces
+    We should also parse responses where integers are separated by spaces,
+    like this: `ua#### #### ###### ###### ###### ######`
+
+    Args:
+      resp: The response string to parse.
+      fmt: The format string.
+
+    Raises:
+      ValueError: if the format string is incompatible with the response.
+
+    Returns:
+      A dictionary containing the parsed values.
+
+    Examples:
+      Parsing a string containing decimals (`1111`), hex (`0xB0B`) and chars (`'rw'`):
+
+      ```
+      >>> parse_fw_string("aa1111bbrwccB0B", "aa####bb&&cc***")
+      {'aa': 1111, 'bb': 'rw', 'cc': 2827}
+      ```
+    """
+
+    # Remove device and cmd identifier from response.
+    resp = resp[4:]
+
+    # Parse the parameters in the fmt string.
+    info = {}
+
+    def find_param(param):
+      name, data = param[0:2], param[2:]
+      type_ = {
+        "#": "int",
+        "*": "hex",
+        "&": "str"
+      }[data[0]]
+
+      # Build a regex to match this parameter.
+      exp = {
+        "int": r"[-+]?[\d ]",
+        "hex": r"[\da-fA-F ]",
+        "str": ".",
+      }[type_]
+      len_ = len(data.split(" ")[0]) # Get length of first block.
+      regex = f"{name}((?:{exp}{ {len_} }"
+
+      if param.endswith(" (n)"):
+        regex += " ?)+)"
+        is_list = True
+      else:
+        regex += "))"
+        is_list = False
+
+      # Match response against regex, save results in right datatype.
+      r = re.search(regex, resp)
+      if r is None:
+        # Don't raise an error if we are looking for the id parameter.
+        # if name == "id":
+          # return None
+        raise ValueError(f"could not find matches for parameter {name}")
+
+      g = r.groups()
+      if len(g) == 0:
+        raise ValueError(f"could not find value for parameter {name}")
+      m = g[0]
+
+      if is_list:
+        m = m.split(" ")
+
+        if type_ == "str":
+          info[name] = m
+        elif type_ == "int":
+          info[name] = [int(m_) for m_ in m if m_ != ""]
+        elif type_ == "hex":
+          info[name] = [int(m_, base=16) for m_ in m if m_ != ""]
+      else:
+        if type_ == "str":
+          info[name] = m
+        elif type_ == "int":
+          info[name] = int(m)
+        elif type_ == "hex":
+          info[name] = int(m, base=16)
+
+    # Find params in string. All params are identified by 2 lowercase chars.
+    param = ""
+    prevchar = None
+    for char in fmt:
+      if char.islower() and prevchar != "(":
+        if len(param) > 2:
+          find_param(param)
+          param = ""
+      param += char
+      prevchar = char
+    if param != "":
+      find_param(param) # last parameter is not closed by loop.
+    if "id" not in info: # auto add id if we don't have it yet.
+      find_param("id####")
+
+    return info
+
+  def parse_response(self, resp: str, fmt: str) -> typing.Tuple[dict, dict]:
+    """ Parse a response from the Hamilton machine.
+
+    This method uses the `parse_fw_string` method to get the info from the response string.
+    Additionally, it finds any errors in the response.
+
+    Args:
+      response: A string containing the response from the Hamilton machine.
+      fmt: A string containing the format of the response.
+
+    Raises:
+      ValueError: if the format string is incompatible with the response.
+      HamiltonException: if the response contains an error.
+
+    Returns:
+      A dictionary containing the parsed response.
+    """
+
+    # Parse errors.
+    module = resp[:2]
+    if module == "C0":
+      # C0 sends errors as er##/##. P1 raises errors as er## where the first group is the error
+      # code, and the second group is the trace information.
+      # Beyond that, specific errors may be added for individual channels and modules. These
+      # are formatted as P1##/## H0##/##, etc. These items are added programmatically as
+      # named capturing groups to the regex.
+
+      exp = r"er(?P<C0>[0-9]{2}/[0-9]{2})"
+      for module in ["X0", "I0", "W1", "W2", "T1", "T2", "R0", "P1", "P2", "P3", "P4", "P5", "P6",
+                    "P7", "P8", "P9", "PA", "PB", "PC", "PD", "PE", "PF", "PG", "H0", "HW", "HU",
+                    "HV", "N0", "D0", "NP", "M1"]:
+        exp += f" ?(?:{module}(?P<{module}>[0-9]{{2}}/[0-9]{{2}}))?"
+      errors = re.search(exp, resp)
+    else:
+      # Other modules send errors as er##, and do not contain slave errors.
+      exp = f"er(?P<{module}>[0-9]{{2}})"
+      errors = re.search(exp, resp)
+
+    if errors is not None:
+      errors = errors.groupdict()
+      errors = {k:v for k,v in errors.items() if v is not None} # filter None elements
+      # filter 00 and 00/00 elements, which mean no error.
+      errors = {k:v for k,v in errors.items() if v not in ["00", "00/00"]}
+
+    has_error = not (errors is None or len(errors) == 0)
+    if has_error:
+      he = HamiltonFirmwareError(errors, raw_response=resp)
+
+      # If there is a faulty parameter error, request which parameter that is.
+      for module_name, error in he.items():
+        if error.message == "Unknown parameter":
+          vp = self.send_command(module=error.raw_module, command="VP", fmt="vp&&")
+          module[module_name] += f" ({vp})"
+
+      raise he
+
+    info = self.parse_fw_string(resp, fmt)
+
+    return info
+
+  def send_command(
+    self,
+    module: str,
+    command: str,
+    timeout: int = 16,
+    fmt: typing.Optional[str]=None,
+    **kwargs
+  ):
+    """ Send a firmware command to the Hamilton machine.
+
+    Args:
+      module: 2 character module identifier (C0 for master, ...)
+      command: 2 character command identifier (QM for request status)
+      timeout: timeout in seconds.
+      fmt: A string containing the format of the response. If None, the raw response is returned.
+      kwargs: any named parameters. the parameter name should also be
+              2 characters long. The value can be any size.
+
+    Raises:
+      HamiltonFirmwareError: if an error response is received.
+
+    Returns:
+      A dictionary containing the parsed response, or None if no response was read within `timeout`.
+    """
+
+    cmd, id_ = self._assemble_command(module, command, **kwargs)
+
+    # write command to endpoint
+    self.dev.write(self.write_endpoint, cmd)
+    logger.info("Sent command: %s", cmd)
+
+    # Read packets until timeout, or when we identify the right id.
+    attempts = 0
+    while attempts < (timeout / self.read_poll_interval):
+      res = self._read_packet()
+      if res is None:
+        continue
+
+      # Parse preliminary response, there may be more data to read, but the first packet of the
+      # response will definitely contain the id of the command we sent. If we find it, we read
+      # the rest of the response, if it is not contained in a single packet.
+      parsed_response = self.parse_fw_string(res)
+      if "id" in parsed_response and f"{parsed_response['id']:04}" == id_:
+        # While length of response is the maximum length, there may be more data to read.
+        last_packet = res
+        while last_packet is not None and len(last_packet) == self.read_endpoint.wMaxPacketSize:
+          last_packet = self._read_packet()
+          if last_packet is not None:
+            res += last_packet
+
+        logger.info("Received response: %s", res)
+
+        # If `fmt` is None, return the raw response.
+        if fmt is None:
+          return res
+        return self.parse_response(res, fmt)
+
+      time.sleep(self.read_poll_interval)
+      attempts += 1
+
+    return None
+
+
+class STAR(HamiltonLiquidHandler):
+  """
+  Interface for the Hamilton STAR.
+  """
+
+  def __init__(self, **kwargs):
+    """ Create a new STAR interface.
+
+    Args:
+      read_poll_interval: The sleep after each check for device responses, in ms.
+    """
+
+    super().__init__(**kwargs)
+    self.dev = None
+    self._tip_types = {}
+
+  def setup(self):
+    """ setup
+
+    Creates a USB connection and finds read/write interfaces.
+    """
+
+    if not USE_USB:
+      raise RuntimeError("USB is not enabled. Please install pyusb.")
+
+    if self.dev is not None:
+      logging.warning("Already initialized. Please call stop() first.")
+      return
+
+    logger.info("Finding Hamilton USB device...")
+
+    self.dev = usb.core.find(idVendor=0x08af)
+    if self.dev is None:
+      raise ValueError("Hamilton STAR device not found.")
+
+    logger.info("Found Hamilton USB device.")
+
+    # set the active configuration. With no arguments, the first
+    # configuration will be the active one
+    self.dev.set_configuration()
+
+    cfg = self.dev.get_active_configuration()
+    intf = cfg[(0,0)]
+
+    self.write_endpoint = usb.util.find_descriptor(
+      intf,
+      custom_match = \
+      lambda e: \
+          usb.util.endpoint_direction(e.bEndpointAddress) == \
+          usb.util.ENDPOINT_OUT) # 0x3?
+
+    self.read_endpoint = usb.util.find_descriptor(
+      intf,
+      custom_match = \
+      lambda e: \
+          usb.util.endpoint_direction(e.bEndpointAddress) == \
+          usb.util.ENDPOINT_IN) # 0x83?
+
+    logger.info("Found endpoints. \nWrite:\n %s \nRead:\n %s", self.write_endpoint, self.read_endpoint)
+
+    initialized = self.request_instrument_initialization_status()
+    if not initialized:
+      logger.info("Running backend initialization procedure.")
+
+      # initialization procedure
+      # TODO: before layout...
+      self.pre_initialize_instrument()
+
+      # TODO: after layout..., need tip types
+      self.initialize_iswap()
+
+      # Spread PIP channels command = JE ? (Spread PIP channels)
+
+      #C0DIid0201xp08000&yp4050 3782 3514 3246 2978 2710 2442 2175tp2450tz1220te2450tm1&tt04ti0
+      self.initialize_pipetting_channels( # spreads channels
+        x_positions=[8000],
+        # dy = 268
+        y_positions=[4050, 3782, 3514, 3246, 2978, 2710, 2442, 2175],
+        begin_of_tip_deposit_process=2450,
+        end_of_tip_deposit_process=1220,
+        z_position_at_end_of_a_command=3600,
+        tip_pattern=[1], # [1] * 8
+        tip_type="04", # TODO: get from tip types
+        discarding_method=0
+      )
+
+  def stop(self):
+    if self.dev is None:
+      raise ValueError("USB device was not connected.")
+    logging.warning("Closing connection to USB device.")
+    usb.util.dispose_resources(self.dev)
+    self.dev = None
+
+  def _read(self):
+    """
+    continuously read data sent by Hamilton device and store each entry
+    with an id in the responses cache.
+    """
+
+    while True:
+      time.sleep(self.read_poll_interval)
+      self.dev.read(self.read_endpoint, 100) # TODO: instead of 100 we want to read until new line.
+      # TODO: what happens when we write 2 commands without reading in between?
+
+  # ============== Tip Types ==============
+
+  def define_tip_type(self, tip_type: TipType):
+    """ Define a new tip type.
+
+    Sends a command to the robot to define a new tip type and save the tip type table index for
+    future reference.
+
+    Args:
+      tip_type: Tip type name.
+
+    Returns:
+      Tip type table index.
+
+    Raises:
+      ValueError: If the tip type is already defined.
+    """
+
+    if tip_type in self._tip_types:
+      raise ValueError(f"Tip type {tip_type} already defined.")
+
+    ttti = len(self._tip_types) + 1
+    if ttti > 99:
+      raise ValueError("Too many tip types defined.")
+
+    # TODO: look up if there are other tip types with the same properties, and use that ID.
+    self.define_tip_needle(
+      tip_type_table_index=ttti,
+      filter=tip_type.has_filter,
+      tip_length=tip_type.tip_length * 10, # in 0.1mm
+      maximum_tip_volume=tip_type.tip_length * 10, # in 0.1ul
+      tip_type=tip_type.tip_type_id,
+      pick_up_method=tip_type.pick_up_method
+    )
+    self._tip_types[tip_type] = ttti
+    return ttti
+
+  def get_tip_type_table_index(self, tip_type: TipType) -> int:
+    """ Get tip type table index.
+
+    Args:
+      tip_type: Tip type.
+
+    Returns:
+      Tip type ID.
+    """
+
+    return self._tip_types[tip_type]
+
+  def get_or_assign_tip_type_index(self, tip_type: TipType) -> int:
+    """ Get a tip type table index for the tip_type if it is defined, otherwise define it and then
+    return it.
+
+    Args:
+      tip_type: Tip type.
+
+    Returns:
+      Tip type IDliquid_handling.
+    """
+
+    if tip_type not in self._tip_types:
+      self.define_tip_type(tip_type)
+    return self.get_tip_type_table_index(tip_type)
+
+  # ============== LiquidHandlerBackend methods ==============
+
+  def assigned_resource_callback(self, resource: Resource):
+    if isinstance(resource, Tips):
+      self.define_tip_type(resource.tip_type)
+
+  def _channel_positions_to_fw_positions(self, channel_positions, resource) -> \
+    typing.Tuple[typing.List[int], typing.List[int], typing.List[bool]]:
+    """ Convert channel positions to firmware positions: `x_positions`, `y_positions`,
+    `channels_involed`.
+
+    Args:
+      channel_positions: List of channel positions.
+      resource: Resource in which the objects with `position` are located.
+
+    Returns:
+      Tuple of `x_positions`, `y_positions`, `channels_involed`.
+    """
+
+    y_positions = []
+    x_positions = []
+    channels_involved = []
+
+    for channel_pos in channel_positions:
+      if channel_pos is None:
+        continue
+
+      row, column = utils.string_to_position(channel_pos)
+
+      # TODO: what is -9?
+      x_positions.append(int((resource.location.x - column*9)*10))
+      y_positions.append(int((resource.location.y - row*9)*10))
+      channels_involved.append(True)
+
+    # TODO: Must have leading zero if len != 8?
+    if len(y_positions) < 8:
+      x_positions.append(0)
+      y_positions.append(0)
+      channels_involved.append(False)
+
+    return (x_positions, y_positions, channels_involved)
+
+  def pickup_tips(
+    self,
+    resource: typing.Union[str, Tips],
+    channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+    **backend_kwargs
+  ):
+    """ Pick up tips from a resource. """
+
+    channels = [channel_1, channel_2, channel_3, channel_4,
+                channel_5, channel_6, channel_7, channel_8]
+
+    x_positions, y_positions, channels_involved = \
+      self._channel_positions_to_fw_positions(channels, resource)
+    ttti = self.get_or_assign_tip_type_index(resource.tip_type)
+
+    params = {
+      "begin_tip_pick_up_process": 2244,
+      "end_tip_pick_up_process": 2164,
+      "minimum_traverse_height_at_beginning_of_a_command": 2450,
+      "pick_up_method": 0
+    }
+    params.update(backend_kwargs)
+
+    return self.pick_up_tip(
+      x_positions=x_positions,
+      y_positions=y_positions,
+      tip_pattern=channels_involved,
+      tip_type=ttti,
+      **params
+    )
+
+  def discard_tips(
+    self,
+    resource: typing.Union[str, Tips],
+    channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+    **backend_kwargs
+  ):
+    """ Discard tips from a resource. """
+
+    channels = [channel_1, channel_2, channel_3, channel_4,
+                channel_5, channel_6, channel_7, channel_8]
+
+    x_positions, y_positions, channels_involved = \
+      self._channel_positions_to_fw_positions(channels, resource)
+    ttti = self.get_or_assign_tip_type_index(resource.tip_type)
+
+    params = {
+      "begin_tip_deposit_process": 2244,
+      "end_tip_deposit_process": 2164,
+      "minimum_traverse_height_at_beginning_of_a_command": 2450,
+      "discarding_method": 0
+    }
+    params.update(backend_kwargs)
+
+    return self.discard_tip(
+      x_positions=x_positions,
+      y_positions=y_positions,
+      tip_pattern=channels_involved,
+      tip_type=ttti,
+      **params
+    )
+
+  def aspirate(
+    self,
+    resource: typing.Union[str, Resource],
+    channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+    **backend_kwargs
+  ):
+    channels = [channel_1, channel_2, channel_3, channel_4,
+                channel_5, channel_6, channel_7, channel_8]
+
+    x_positions, y_positions, channels_involved = \
+      self._channel_positions_to_fw_positions([c.position for c in channels if c is not None],
+        resource)
+
+    # Copied over from old command, some parameters had strange additional values. Can those be
+    # removed?
+    # lld_search_height=[2321] * num_wells, # TODO: is this necessary? + [2450],
+    # clot_detection_height=[0] * num_wells, # TODO: is this necessary? + [0],
+    # liquid_surface_no_lld=[1881] * num_wells, # TODO: is this necessary? + [2450],
+    # second_section_height=[32] * num_wells, # TODO: is this necessary? + [0],
+    # second_section_ratio=[6180] * num_wells, # TODO: is this necessary? + [0],
+    # minimum_height=[1871] * num_wells, # TODO: is this necessary? + [0],
+
+    params = []
+
+    # Correct volumes for liquid class. Then multiply by 10 to get to units of 0.1uL. Also get
+    # all other aspiration parameters.
+    for channel in itertools.compress(channels, channels_involved):
+      params.append({
+        "aspiration_volumes": int(channel.get_corrected_volume() * 10),
+        "lld_search_height": 2321,
+        "clot_detection_height": 0,
+        "liquid_surface_no_lld": 1881,
+        "pull_out_distance_transport_air": 100,
+        "second_section_height": 32,
+        "second_section_ratio": 6180,
+        "minimum_height": 1871,
+        "immersion_depth": 0,
+        "immersion_depth_direction": 0,
+        "surface_following_distance": 0,
+        "aspiration_speed": 1000,
+        "transport_air_volume": 0,
+        "blow_out_air_volume": 0,
+        "pre_wetting_volume": 0,
+        "lld_mode": 0,
+        "gamma_lld_sensitivity": 1,
+        "dp_lld_sensitivity": 1,
+        "aspirate_position_above_z_touch_off": 0,
+        "detection_height_difference_for_dual_lld": 0,
+        "swap_speed": 20,
+        "settling_time": 10,
+        "homogenization_volume": 0,
+        "homogenization_cycles": 0,
+        "homogenization_position_from_liquid_surface": 0,
+        "homogenization_speed": 1000,
+        "homogenization_surface_following_distance": 0,
+        "limit_curve_index": 0,
+
+        "use_2nd_section_aspiration": 0,
+        "retract_height_over_2nd_section_to_empty_tip": 0,
+        "dispensation_speed_during_emptying_tip": 500,
+        "dosing_drive_speed_during_2nd_section_search": 500,
+        "z_drive_speed_during_2nd_section_search": 300,
+        "cup_upper_edge": 0,
+        "ratio_liquid_rise_to_tip_deep_in": 0,
+        "immersion_depth_2nd_section": 0
+      })
+
+    cmd_kwargs = {
+      "minimum_traverse_height_at_beginning_of_a_command": 2450,
+      "min_z_endpos": 2450,
+    }
+
+    # Convert the list of dictionaries to a single dictionary where all values for the same key are
+    # accumulated in a list with that key.
+    for kwargs in params:
+      for key, value in kwargs.items():
+        if key not in cmd_kwargs:
+          cmd_kwargs[key] = []
+        cmd_kwargs[key].append(value)
+
+    # Update kwargs with user properties.
+    cmd_kwargs.update(backend_kwargs)
+
+    return self.aspirate_pip(
+      tip_pattern=channels_involved,
+      x_positions=x_positions,
+      y_positions=y_positions,
+      **cmd_kwargs,
+    )
+
+  def dispense(
+    self,
+    resource: typing.Union[str, Plate],
+    channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+    **backend_kwargs
+  ):
+    channels = [channel_1, channel_2, channel_3, channel_4,
+                channel_5, channel_6, channel_7, channel_8]
+
+    x_positions, y_positions, channels_involved = \
+      self._channel_positions_to_fw_positions([c.position for c in channels if c is not None],
+        resource)
+
+    # Copied over from old command, some parameters had strange additional values. Can those be
+    # removed?
+    # lld_search_height=[2321] * num_wells, # TODO: is this necessary? + [2450],
+    # liquid_surface_no_lld=[1881] * num_wells, # TODO: is this necessary? + [2450],
+    # pull_out_distance_transport_air=[100],
+    # second_section_height=[32] * num_wells, # TODO: is this necessary? + [0],
+    # second_section_ratio=[6180] * num_wells, # TODO: is this necessary? + [0],
+    # minimum_height=[1871] * num_wells, # TODO: is this necessary? + [0],
+
+    params = []
+
+    for channel in itertools.compress(channels, channels_involved):
+      params.append({
+        "dispensing_mode": 2,
+        "dispense_volumes": int(channel.get_corrected_volume() * 10),
+        "lld_search_height": 2321,
+        "liquid_surface_no_lld": 1881,
+        "pull_out_distance_transport_air": 100,
+        "second_section_height": 32,
+        "second_section_ratio": 6180,
+        "minimum_height": 1871,
+        "immersion_depth": 0,
+        "immersion_depth_direction": 0,
+        "surface_following_distance": 0,
+        "dispense_speed": 1200,
+        "cut_off_speed": 50,
+        "stop_back_volume": 0,
+        "transport_air_volume": 0,
+        "blow_out_air_volume": 0,
+        "lld_mode": 0,
+        "dispense_position_above_z_touch_off": 0,
+        "gamma_lld_sensitivity": 1,
+        "dp_lld_sensitivity": 1,
+        "swap_speed": 20,
+        "settling_time": 0,
+        "mix_volume": 0,
+        "mix_cycles": 0,
+        "mix_position_from_liquid_surface": 0,
+        "mix_speed": 10,
+        "mix_surface_following_distance": 0,
+        "limit_curve_index": 0
+      })
+
+    cmd_kwargs = {
+      "minimum_traverse_height_at_beginning_of_a_command": 2450,
+      "min_z_endpos": 2450,
+      "side_touch_off_distance": 0,
+    }
+
+    # Convert the list of dictionaries to a single dictionary where all values for the same key are
+    # accumulated in a list with that key.
+    for kwargs in params:
+      for key, value in kwargs.items():
+        if key not in cmd_kwargs:
+          cmd_kwargs[key] = []
+        cmd_kwargs[key].append(value)
+
+    # Update kwargs with user properties.
+    cmd_kwargs.update(backend_kwargs)
+
+    return self.dispense_pip(
+      tip_pattern=channels_involved,
+      x_positions=x_positions,
+      y_positions=y_positions,
+      **cmd_kwargs
+    )
+
+  def pickup_tips96(self, resource):
+    raise NotImplementedError()
+
+  def discard_tips96(self, resource):
+    raise NotImplementedError()
+
+  def aspirate96(self, resource, pattern, volume):
+    raise NotImplementedError()
+
+  def dispense96(self, resource, pattern, volume):
+    raise NotImplementedError()
+
+  # ============== Firmware Commands ==============
+
+  # -------------- 3.2 System general commands --------------
+
+  def pre_initialize_instrument(self):
+    """ Pre-initialize instrument """
+    resp = self.send_command(module="C0", command="VI")
+    return self.parse_response(resp, "")
+
+  class FirmwareTipType(enum.Enum):
+    """ Tip type """
+    UNDEFINED=0
+    LOW_VOLUME=1
+    STANDARD_VOLUME=2
+    HIGH_VOLUME=3
+    CORE_384_HEAD_TIP=4
+    XL=5
+
+  class PickUpMethod(enum.Enum):
+    """ Tip pick up method """
+    OUT_OF_RACK=0
+    OUT_OF_WASH_LIQUID=1
+
+  def define_tip_needle(
+    self,
+    tip_type_table_index: int = 4,
+    filter: bool = False,
+    tip_length: int = 1950,
+    maximum_tip_volume: int = 3500,
+    tip_type: FirmwareTipType = FirmwareTipType.STANDARD_VOLUME,
+    pick_up_method: PickUpMethod = PickUpMethod.OUT_OF_RACK
+  ):
+    """ Tip/needle definition.
+
+    TODO: Define default values for type/application/filter.
+
+    Args:
+      tip_type_table_index: tip_table_index
+      filter: with(out) filter
+      tip_length: Tip length [0.1mm]
+      maximum_tip_volume: Maximum volume of tip [0.1ul]
+                          Note! it's automatically limited to max. channel capacity
+      tip_type: Type of tip collar (Tip type identification)
+      pick_up_method: pick up method.
+                      Attention! The values set here are temporary and apply only until
+                      power OFF or RESET. After power ON the default val- ues apply. (see Table 3)
+    """
+
+    # pylint: disable=redefined-builtin
+
+    utils.assert_clamp(tip_type_table_index, 0, 99, "tip_type_table_index")
+    filter = 1 if filter else 0
+    utils.assert_clamp(tip_length, 1, 1999, "tip_length")
+    utils.assert_clamp(maximum_tip_volume, 1, 56000, "maximum_tip_volume")
+
+    return self.send_command(
+      module="C0",
+      command="TT",
+      tt=f"{tip_type_table_index:02}",
+      tf=filter,
+      tl=f"{tip_length:04}",
+      tv=f"{maximum_tip_volume:05}",
+      tg=tip_type,
+      tu=pick_up_method
+    )
+
+  # -------------- 3.2.1 System query --------------
+
+  def request_error_code(self):
+    """ Request error code
+
+    Here the last saved error messages can be retrieved. The error buffer
+    is automatically voided when a new command is started.
+    All configured nodes are displayed.
+
+    Returns:
+      TODO:
+      X0##/##: X0 slave
+      ..##/## see node definitions ( chapter 5)
+    """
+
+    resp = self.send_command(module="RE", command="RF")
+    return resp
+
+  def request_firmware_version(self):
+    """ Request firmware version
+
+    Returns: TODO: Rfid0001rf1.0S 2009-06-24 A
+    """
+    return self.send_command(module="C0", command="RF")
+
+  def request_parameter_value(self):
+    """ Request parameter value
+
+    Returns: TODO: Raid1111er00/00yg1200
+    """
+
+    return self.send_command(module="C0", command="RA")
+
+  class BoardType(enum.Enum):
+    C167CR_SINGLE_PROCESSOR_BOARD = 0
+    C167CR_DUAL_PROCESSOR_BOARD = 1
+    LPC2468_XE167_DUAL_PROCESSOR_BOARD = 2
+    LPC2468_SINGLE_PROCESSOR_BOARD = 5
+    UNKNOWN = -1
+
+  def request_electronic_board_type(self):
+    """ Request electronic board type
+
+    Returns:
+      The board type.
+    """
+
+    # pylint: disable=undefined-variable
+
+    resp = self.send_command(module="C0", command="QB")
+    try:
+      return STAR.BoardType(resp["qb"])
+    except ValueError:
+      return STAR.BoardType.UNKNOWN
+
+  # TODO: parse response.
+  def request_supply_voltage(self):
+    """ Request supply voltage
+
+    Request supply voltage (for LDPB only)
+    """
+
+    return self.send_command(module="C0", command="MU")
+
+  def request_instrument_initialization_status(self):
+    """ Request instrument initialization status """
+
+    return self.send_command(module="C0", command="QW", fmt="qw#")["qw"] == 1
+
+  def request_name_of_last_faulty_parameter(self):
+    """ Request name of last faulty parameter
+
+    Returns: TODO:
+      Name of last parameter with syntax error
+      (optional) received value separated with blank
+      (optional) minimal permitted value separated with blank (optional)
+      maximal permitted value separated with blank example with min max data:
+      Vpid2233er00/00vpth 00000 03500 example without min max data: Vpid2233er00/00vpcd
+    """
+
+    return self.send_command(module="C0", command="VP")
+
+  def request_master_status(self):
+    """ Request master status
+
+    Returns: TODO: see page 19 (SFCO.0036)
+    """
+
+    return self.send_command(module="C0", command="RQ")
+
+  def request_number_of_presence_sensors_installed(self):
+    """ Request number of presence sensors installed
+
+    Returns:
+      number of sensors installed (1...103)
+    """
+
+    return self.send_command(module="C0", command="SR")["sr"]
+
+  def request_eeprom_data_correctness(self):
+    """ Request EEPROM data correctness
+
+    Returns: TODO: (SFCO.0149)
+    """
+
+    return self.send_command(module="C0", command="QV")
+
+  # -------------- 3.3 Settings --------------
+
+  # -------------- 3.3.1 Volatile Settings --------------
+
+
+  def set_single_step_mode(
+    self,
+    single_step_mode: bool = False
+  ):
+    """ Set Single step mode
+
+    Args:
+      single_step_mode: Single Step Mode. Default False.
+    """
+
+    return self.send_command(
+      module="C0",
+      command="AM",
+      am=single_step_mode,
+    )
+
+  def trigger_next_step(self):
+    """ Trigger next step (Single step mode) """
+
+    # TODO: this command has no reply!!!!
+    return self.send_command(module="C0", command="NS")
+
+  def halt(self):
+    """ Halt
+
+    Intermediate sequences not yet carried out and the commands in
+    the command stack are discarded. Sequence already in process is
+    completed.
+    """
+
+    return self.send_command(module="C0", command="HD")
+
+  def save_all_cycle_counters(self):
+    """ Save all cycle counters
+
+    Save all cycle counters of the instrument
+    """
+
+    return self.send_command(module="C0", command="AZ")
+
+  def set_not_stop(self, non_stop):
+    """ Set not stop mode
+
+    Args:
+      non_stop: True if non stop mode should be turned on after command is sent.
+    """
+
+    if non_stop:
+      # TODO: this command has no reply!!!!
+      return self.send_command(module="C0", command="AB")
+    else:
+      return self.send_command(module="C0", command="AW")
+
+  # -------------- 3.3.2 Non volatile settings (stored in EEPROM) --------------
+
+  def store_installation_data(
+    self,
+    date: datetime.datetime = datetime.datetime.now(),
+    serial_number: str = "0000"
+  ):
+    """ Store installation data
+
+    Args:
+      date: installation date.
+    """
+
+    assert len(serial_number) == 4, "serial number must be 4 chars long"
+
+    return self.send_command(
+      module="C0",
+      command="SI",
+      si=date,
+      sn=serial_number
+    )
+
+  def store_verification_data(
+    self,
+    verification_subject: int = 0,
+    date: datetime.datetime = datetime.datetime.now(),
+    verification_status: bool = None
+  ):
+    """ Store verification data
+
+    Args:
+      verification_subject: verification subject. Default 0. Must be between 0 and 24.
+      date: verification date.
+      verification_status: verification status.
+    """
+
+    utils.assert_clamp(verification_subject, 0, 24, "verification_subject")
+
+    return self.send_command(
+      module="C0",
+      command="AV",
+      vo=verification_subject,
+      vd=date,
+      vs=verification_status,
+    )
+
+  def additional_time_stamp(self):
+    """ Additional time stamp """
+
+    return self.send_command(module="C0", command="AT")
+
+  def set_x_offset_x_axis_iswap(self, x_offset: int):
+    """ Set X-offset X-axis <-> iSWAP
+
+    Args:
+      x_offset: X-offset [0.1mm]
+    """
+
+    return self.send_command(
+      module="C0",
+      command="AG",
+      x_offset=x_offset
+    )
+
+  def set_x_offset_x_axis_core_96_head(self, x_offset: int):
+    """ Set X-offset X-axis <-> CoRe 96 head
+
+    Args:
+      x_offset: X-offset [0.1mm]
+    """
+
+    return self.send_command(
+      module="C0",
+      command="AF",
+      x_offset=x_offset
+    )
+
+  def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int):
+    """ Set X-offset X-axis <-> CoRe 96 head
+
+    Args:
+      x_offset: X-offset [0.1mm]
+    """
+
+    return self.send_command(
+      module="C0",
+      command="AF",
+      x_offset=x_offset
+    )
+
+  def save_download_date(
+    self,
+    date: datetime.datetime = datetime.datetime.now()
+  ):
+    """ Save Download date
+
+    Args:
+      date: download date. Default now.
+    """
+
+    return self.send_command(
+      module="C0",
+      command="AO",
+      ao=date,
+    )
+
+  def save_technical_status_of_assemblies(
+    self,
+    processor_board: str,
+    power_supply: str
+  ):
+    """ Save technical status of assemblies
+
+    Args:
+      processor_board: Processor board. Art.Nr./Rev./Ser.No. (000000/00/0000)
+      power_supply: Power supply. Art.Nr./Rev./Ser.No. (000000/00/0000)
+    """
+
+    return self.send_command(
+      module="C0",
+      command="BT",
+      qt=processor_board + " " + power_supply,
+    )
+
+  def set_instrument_configuration(
+    self,
+    configuration_data_1: str = None, # TODO: configuration byte
+    configuration_data_2: str = None, # TODO: configuration byte
+    configuration_data_3: str = None, # TODO: configuration byte
+    instrument_size_in_slots_x_range: int = 54,
+    auto_load_size_in_slots: int = 54,
+    tip_waste_x_position: int = 13400,
+    right_x_drive_configuration_byte_1: int = 0,
+    right_x_drive_configuration_byte_2: int = 0,
+    minimal_iswap_collision_free_position: int = 3500,
+    maximal_iswap_collision_free_position: int = 11400,
+    left_x_arm_width: int = 3700,
+    right_x_arm_width: int = 3700,
+    num_pip_channels: int = 0,
+    num_xl_channels: int = 0,
+    num_robotic_channels: int = 0,
+    minimal_raster_pitch_of_pip_channels: int = 90,
+    minimal_raster_pitch_of_xl_channels: int = 360,
+    minimal_raster_pitch_of_robotic_channels: int = 360,
+    pip_maximal_y_position: int = 6065,
+    left_arm_minimal_y_position: int = 60,
+    right_arm_minimal_y_position: int = 60
+  ):
+    """ Set instrument configuration
+
+    Args:
+      configuration_data_1: configuration data 1.
+      configuration_data_2: configuration data 2.
+      configuration_data_3: configuration data 3.
+      instrument_size_in_slots_x_range: instrument size in slots (X range).
+                                          Must be between 10 and 99. Default 54.
+      auto_load_size_in_slots: auto load size in slots. Must be between 10
+                                and 54. Default 54.
+      tip_waste_x_position: tip waste X-position. Must be between 1000 and
+                            25000. Default 13400.
+      right_x_drive_configuration_byte_1: right X drive configuration byte 1 (see
+                                          xl parameter bits). Must be between 0 and 1.
+                                          Default 0. # TODO: this.
+      right_x_drive_configuration_byte_2: right X drive configuration byte 2 (see
+                                          xn parameter bits). Must be between 0 and 1.
+                                          Default 0. # TODO: this.
+      minimal_iswap_collision_free_position: minimal iSWAP collision free position for
+                                            direct X access. For explanation of calculation
+                                            see Fig. 4. Must be between 0 and 30000. Default 3500.
+      maximal_iswap_collision_free_position: maximal iSWAP collision free position for
+                                              direct X access. For explanation of calculation
+                                              see Fig. 4. Must be between 0 and 30000. Default 11400
+      left_x_arm_width: width of left X arm [0.1 mm]. Must be between 0 and 9999. Default 3700.
+      right_x_arm_width: width of right X arm [0.1 mm]. Must be between 0 and 9999. Default 3700.
+      num_pip_channels: number of PIP channels. Must be between 0 and 16. Default 0.
+      num_xl_channels: number of XL channels. Must be between 0 and 8. Default 0.
+      num_robotic_channels: number of Robotic channels. Must be between 0 and 8. Default 0.
+      minimal_raster_pitch_of_pip_channels: minimal raster pitch of PIP channels [0.1 mm]. Must
+                                            be between 0 and 999. Default 90.
+      minimal_raster_pitch_of_xl_channels: minimal raster pitch of XL channels [0.1 mm]. Must be
+                                            between 0 and 999. Default 360.
+      minimal_raster_pitch_of_robotic_channels: minimal raster pitch of Robotic channels [0.1 mm].
+                                                Must be between 0 and 999. Default 360.
+      pip_maximal_y_position: PIP maximal Y position [0.1 mm]. Must be between 0 and 9999.
+                              Default 6065.
+      left_arm_minimal_y_position: left arm minimal Y position [0.1 mm]. Must be between 0 and 9999.
+                                    Default 60.
+      right_arm_minimal_y_position: right arm minimal Y position [0.1 mm]. Must be between 0
+                                    and 9999. Default 60.
+    """
+
+    utils.assert_clamp(instrument_size_in_slots_x_range, 10, 99, "instrument_size_in_slots_(x_range)")
+    utils.assert_clamp(auto_load_size_in_slots, 10, 54, "auto_load_size_in_slots")
+    utils.assert_clamp(tip_waste_x_position, 1000, 25000, "tip_waste_x_position")
+    utils.assert_clamp(right_x_drive_configuration_byte_1, 0, 1, "right_x_drive_configuration_byte_1")
+    utils.assert_clamp(right_x_drive_configuration_byte_2, 0, 1, "right_x_drive_configuration_byte_2")
+    utils.assert_clamp(minimal_iswap_collision_free_position, 0, 30000, \
+                  "minimal_iswap_collision_free_position")
+    utils.assert_clamp(maximal_iswap_collision_free_position, 0, 30000, \
+                  "maximal_iswap_collision_free_position")
+    utils.assert_clamp(left_x_arm_width, 0, 9999, "left_x_arm_width")
+    utils.assert_clamp(right_x_arm_width, 0, 9999, "right_x_arm_width")
+    utils.assert_clamp(num_pip_channels, 0, 16, "num_pip_channels")
+    utils.assert_clamp(num_xl_channels, 0, 8, "num_xl_channels")
+    utils.assert_clamp(num_robotic_channels, 0, 8, "num_robotic_channels")
+    utils.assert_clamp(minimal_raster_pitch_of_pip_channels, 0, 999, \
+                  "minimal_raster_pitch_of_pip_channels")
+    utils.assert_clamp(minimal_raster_pitch_of_xl_channels, 0, 999, \
+                  "minimal_raster_pitch_of_xl_channels")
+    utils.assert_clamp(minimal_raster_pitch_of_robotic_channels, 0, 999, \
+                  "minimal_raster_pitch_of_robotic_channels")
+    utils.assert_clamp(pip_maximal_y_position, 0, 9999, "pip_maximal_y_position")
+    utils.assert_clamp(left_arm_minimal_y_position, 0, 9999, "left_arm_minimal_y_position")
+    utils.assert_clamp(right_arm_minimal_y_position, 0, 9999, "right_arm_minimal_y_position")
+
+    return self.send_command(
+        module="C0",
+        command="AK",
+        kb=configuration_data_1,
+        ka=configuration_data_2,
+        ke=configuration_data_3,
+        xt=instrument_size_in_slots_x_range,
+        xa=auto_load_size_in_slots,
+        xw=tip_waste_x_position,
+        xr=right_x_drive_configuration_byte_1,
+        xo=right_x_drive_configuration_byte_2,
+        xm=minimal_iswap_collision_free_position,
+        xx=maximal_iswap_collision_free_position,
+        xu=left_x_arm_width,
+        xv=right_x_arm_width,
+        kp=num_pip_channels,
+        kc=num_xl_channels,
+        kr=num_robotic_channels,
+        ys=minimal_raster_pitch_of_pip_channels,
+        kl=minimal_raster_pitch_of_xl_channels,
+        km=minimal_raster_pitch_of_robotic_channels,
+        ym=pip_maximal_y_position,
+        yu=left_arm_minimal_y_position,
+        yx=right_arm_minimal_y_position,
+      )
+
+  def save_pip_channel_validation_status(
+    self,
+    validation_status: bool = False
+  ):
+    """ Save PIP channel validation status
+
+    Args:
+      validation_status: PIP channel validation status. Default False.
+    """
+
+    return self.send_command(
+      module="C0",
+      command="AJ",
+      tq=validation_status,
+    )
+
+  def save_xl_channel_validation_status(
+    self,
+    validation_status: bool = False
+  ):
+    """ Save XL channel validation status
+
+    Args:
+      validation_status: XL channel validation status. Default False.
+    """
+
+    return self.send_command(
+      module="C0",
+      command="AE",
+      tx=validation_status,
+    )
+
+  # TODO: response
+  def configure_node_names(self):
+    """ Configure node names """
+
+    return self.send_command(module="C0", command="AJ")
+
+  def set_deck_data(
+    self,
+    data_index: int = 0,
+    data_stream: str = "0"
+  ):
+    """ set deck data
+
+    Args:
+      data_index: data index. Must be between 0 and 9. Default 0.
+      data_stream: data stream (12 characters). Default <class 'str'>.
+    """
+
+    utils.assert_clamp(data_index, 0, 9, "data_index")
+    assert len(data_stream) == 12, "data_stream must be 12 chars"
+
+    return self.send_command(
+      module="C0",
+      command="DD",
+      vi=data_index,
+      vj=data_stream,
+    )
+
+  # -------------- 3.3.3 Settings query (stored in EEPROM) --------------
+
+  def request_technical_status_of_assemblies(self):
+    """ Request Technical status of assemblies """
+
+    # TODO: parse res
+    return self.send_command(module="C0", command="QT")
+
+  def request_installation_data(self):
+    """ Request installation data """
+
+    # TODO: parse res
+    return self.send_command(module="C0", command="RI")
+
+  def request_download_date(self):
+    """ Request download date """
+
+    # TODO: parse res
+    return self.send_command(module="C0", command="RO")
+
+  def request_verification_data(
+    self,
+    verification_subject: int = 0
+  ):
+    """ Request download date
+
+    Args:
+      verification_subject: verification subject. Must be between 0 and 24. Default 0.
+    """
+
+    utils.assert_clamp(verification_subject, 0, 24, "verification_subject")
+
+    # TODO: parse results.
+    return self.send_command(
+      module="C0",
+      command="RO",
+      vo = verification_subject
+    )
+
+  def request_additional_timestamp_data(self):
+    """ Request additional timestamp data """
+
+    # TODO: parse res
+    return self.send_command(module="C0", command="RS")
+
+  def request_pip_channel_validation_status(self):
+    """ Request PIP channel validation status """
+
+    # TODO: parse res
+    return self.send_command(module="C0", command="RJ")
+
+  def request_xl_channel_validation_status(self):
+    """ Request XL channel validation status """
+
+    # TODO: parse res
+    return self.send_command(module="C0", command="UJ")
+
+  def request_machine_configuration(self):
+    """ Request machine configuration """
+
+    # TODO: parse res
+    return self.send_command(module="C0", command="RM")
+
+  def request_extended_configuration(self):
+    """ Request extended configuration """
+
+    resp = self.send_command(module="C0", command="QM")
+    return self.parse_response(resp, fmt="QMid####ka******ke********xt##xa##xw#####xl**" + \
+            "xn**xr**xo**xm#####xx#####xu####xv####kc#kr#ys###kl###km###ym####yu####yx####")
+
+  def request_node_names(self):
+    """ Request node names """
+
+    # TODO: parse res
+    return self.send_command(module="C0", command="RK")
+
+  def request_deck_data(self):
+    """ Request deck data """
+
+    # TODO: parse res
+    return self.send_command(module="C0", command="VD")
+
+  # -------------- 3.4 X-Axis control --------------
+
+  # -------------- 3.4.1 Movements --------------
+
+  def position_left_x_arm_(
+    self,
+    x_position: int = 0
+  ):
+    """ Position left X-Arm
+
+    Collision risk!
+
+    Args:
+      x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position_[0.1mm]")
+
+    resp = self.send_command(
+      module="C0",
+      command="JX",
+      xs=x_position,
+    )
+    return self.parse_response(resp, "")
+
+  def position_right_x_arm_(
+    self,
+    x_position: int = 0
+  ):
+    """ Position right X-Arm
+
+    Collision risk!
+
+    Args:
+      x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position_[0.1mm]")
+
+    resp = self.send_command(
+      module="C0",
+      command="JX",
+      xs=x_position,
+    )
+    return self.parse_response(resp, "")
+
+  def move_left_x_arm_to_position_with_all_attached_components_in_z_safety_position(
+    self,
+    x_position: int = 0
+  ):
+    """ Move left X-arm to position with all attached components in Z-safety position
+
+    Args:
+      x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+
+    resp = self.send_command(
+      module="C0",
+      command="KX",
+      xs=x_position,
+    )
+    return self.parse_response(resp, "")
+
+  def move_right_x_arm_to_position_with_all_attached_components_in_z_safety_position(
+    self,
+    x_position: int = 0
+  ):
+    """ Move right X-arm to position with all attached components in Z-safety position
+
+    Args:
+      x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+
+    resp = self.send_command(
+      module="C0",
+      command="KR",
+      xs=x_position,
+    )
+    return self.parse_response(resp, "")
+
+  # -------------- 3.4.2 X-Area reservation for external access --------------
+
+  def occupy_and_provide_area_for_external_access(
+    self,
+    taken_area_identification_number: int = 0,
+    taken_area_left_margin: int = 0,
+    taken_area_left_margin_direction: int = 0,
+    taken_area_size: int = 0,
+    arm_preposition_mode_related_to_taken_areas: int = 0
+  ):
+    """ Occupy and provide area for external access
+
+    Args:
+      taken_area_identification_number: taken area identification number. Must be between 0 and
+        9999. Default 0.
+      taken_area_left_margin: taken area left margin. Must be between 0 and 99. Default 0.
+      taken_area_left_margin_direction: taken area left margin direction. 1 = negative. Must be
+        between 0 and 1. Default 0.
+      taken_area_size: taken area size. Must be between 0 and 50000. Default 0.
+      arm_preposition_mode_related_to_taken_areas: 0) left arm to left & right arm to right.
+        1) all arms left.  2) all arms right.
+    """
+
+    utils.assert_clamp(taken_area_identification_number, 0, 9999, \
+                  "taken_area_identification_number")
+    utils.assert_clamp(taken_area_left_margin, 0, 99, "taken_area_left_margin")
+    utils.assert_clamp(taken_area_left_margin_direction, 0, 1, "taken_area_left_margin_direction")
+    utils.assert_clamp(taken_area_size, 0, 50000, "taken_area_size")
+    utils.assert_clamp(arm_preposition_mode_related_to_taken_areas, 0, 2, \
+                  "arm_preposition_mode_(related_to_taken_area)s")
+
+    resp = self.send_command(
+      module="C0",
+      command="BA",
+      aq=taken_area_identification_number,
+      al=taken_area_left_margin,
+      ad=taken_area_left_margin_direction,
+      ar=taken_area_size,
+      ap=arm_preposition_mode_related_to_taken_areas,
+    )
+    return self.parse_response(resp, "")
+
+  def release_occupied_area(
+    self,
+    taken_area_identification_number: int = 0
+  ):
+    """ Release occupied area
+
+    Args:
+      taken_area_identification_number: taken area identification number.
+                                        Must be between 0 and 9999. Default 0.
+    """
+
+    utils.assert_clamp(taken_area_identification_number, 0, 9999, "taken_area_identification_number")
+
+    resp = self.send_command(
+      module="C0",
+      command="BB",
+      aq=taken_area_identification_number,
+    )
+    return self.parse_response(resp, "")
+
+  def release_all_occupied_areas(self):
+    """ Release all occupied areas """
+
+    resp = self.send_command(module="C0", command="BC")
+    return resp
+
+  # -------------- 3.4.3 X-query --------------
+
+  def request_left_x_arm_position(self):
+    """ Request left X-Arm position """
+
+    resp = self.send_command(module="C0", command="RX")
+    return self.parse_response(resp, "rx#####")
+
+  def request_right_x_arm_position(self):
+    """ Request right X-Arm position """
+
+    resp = self.send_command(module="C0", command="QX")
+    return self.parse_response(resp, "rx#####")
+
+  def request_maximal_ranges_of_x_drives(self):
+    """ Request maximal ranges of X drives """
+
+    resp = self.send_command(module="C0", command="RU")
+    return self.parse_response(resp, "")
+
+  def request_present_wrap_size_of_installed_arms(self):
+    """ Request present wrap size of installed arms """
+
+    resp = self.send_command(module="C0", command="UA")
+    return self.parse_response(resp, "")
+
+  def request_left_x_arm_last_collision_type(self):
+    """ Request left X-Arm last collision type (after error 27)
+
+    Returns:
+      False if present positions collide (not reachable),
+      True if position is never reachable.
+    """
+
+    resp = self.send_command(module="C0", command="XX")
+    parsed = self.parse_response(resp, "xq#")
+    return parsed["xq"] == 1
+
+  def request_right_x_arm_last_collision_type(self) -> bool:
+    """ Request right X-Arm last collision type (after error 27)
+
+    Returns:
+      False if present positions collide (not reachable),
+      True if position is never reachable.
+    """
+
+    resp = self.send_command(module="C0", command="XR")
+    parsed = self.parse_response(resp, "xq#")
+    return parsed["xq"] == 1
+
+  # -------------- 3.5 Pipetting channel commands --------------
+
+  # -------------- 3.5.1 Initialization --------------
+
+  def initialize_pipetting_channels(
+    self,
+    x_positions: typing.List[int] = [0],
+    y_positions: typing.List[int] = [0],
+    begin_of_tip_deposit_process: int = 0,
+    end_of_tip_deposit_process: int = 0,
+    z_position_at_end_of_a_command: int = 3600,
+    tip_pattern: typing.List[bool] = [True],
+    tip_type: int = 16,
+    discarding_method: int = 1
+  ):
+    """ Initialize pipetting channels
+
+    Initialize pipetting channels (discard tips)
+
+    Args:
+      x_positions: X-Position [0.1mm] (discard position). Must be between 0 and 25000. Default 0.
+      y_positions: y-Position [0.1mm] (discard position). Must be between 0 and 6500. Default 0.
+      begin_of_tip_deposit_process: Begin of tip deposit process (Z-discard range) [0.1mm]. Must be
+        between 0 and 3600. Default 0.
+      end_of_tip_deposit_process: End of tip deposit process (Z-discard range) [0.1mm]. Must be
+        between 0 and 3600. Default 0.
+      z-position_at_end_of_a_command: Z-Position at end of a command [0.1mm]. Must be between 0 and
+        3600. Default 3600.
+      tip_pattern: Tip pattern ( channels involved). Default True.
+      tip_type: Tip type (recommended is index of longest tip see command 'TT') [0.1mm]. Must be
+        between 0 and 99. Default 16.
+      discarding_method: discarding method. 0 = place & shift (tp/ tz = tip cone end height), 1 =
+        drop (no shift) (tp/ tz = stop disk height). Must be between 0 and 1. Default 1.
+    """
+
+    utils.assert_clamp(x_positions, 0, 25000, "x_positions")
+    utils.assert_clamp(y_positions, 0, 6500, "y_positions")
+    utils.assert_clamp(begin_of_tip_deposit_process, 0, 3600, "begin_of_tip_deposit_process")
+    utils.assert_clamp(end_of_tip_deposit_process, 0, 3600, "end_of_tip_deposit_process")
+    utils.assert_clamp(z_position_at_end_of_a_command, 0, 3600, "z_position_at_end_of_a_command")
+    utils.assert_clamp(tip_type, 0, 99, "tip_type")
+    utils.assert_clamp(discarding_method, 0, 1, "discarding_method")
+
+    return self.send_command(
+      module="C0",
+      command="DI",
+      xp=[f"{xp:05}" for xp in x_positions],
+      yp=[f"{yp:04}" for yp in y_positions],
+      tp=f"{begin_of_tip_deposit_process:04}",
+      tz=f"{end_of_tip_deposit_process:04}",
+      te=f"{z_position_at_end_of_a_command:04}",
+      tm=[f"{tm:01}" for tm in tip_pattern],
+      tt=f'{tip_type:02}',
+      ti=discarding_method,
+    )
+
+  # -------------- 3.5.2 Tip handling commands using PIP --------------
+
+  def pick_up_tip(
+    self,
+    x_positions: int = 0, # TODO: these are probably lists.
+    y_positions: int = 0, # TODO: these are probably lists.
+    tip_pattern: bool = True,
+    tip_type: FirmwareTipType = FirmwareTipType.STANDARD_VOLUME,
+    begin_tip_pick_up_process: int = 0,
+    end_tip_pick_up_process: int = 0,
+    minimum_traverse_height_at_beginning_of_a_command: int = 3600,
+    pick_up_method: int=0 #PickUpMethod = PickUpMethod.OUT_OF_RACK
+  ):
+    """ Tip Pick-up
+
+    Args:
+      x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0.
+      y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0.
+      tip_pattern: Tip pattern (channels involved).
+      tip_type: Tip type.
+      begin_tip_pick_up_process: Begin of tip picking up process (Z- range) [0.1mm]. Must be
+          between 0 and 3600. Default 0.
+      end_tip_pick_up_process: End of tip picking up process (Z- range) [0.1mm]. Must be
+          between 0 and 3600. Default 0.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning
+          of a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm').
+          Must be between 0 and 3600. Default 3600.
+      pick_up_method: Pick up method.
+    """
+
+    utils.assert_clamp(x_positions, 0, 25000, "x_positions")
+    utils.assert_clamp(y_positions, 0, 6500, "y_positions")
+    utils.assert_clamp(begin_tip_pick_up_process, 0, 3600, "begin_tip_pick_up_process")
+    utils.assert_clamp(end_tip_pick_up_process, 0, 3600, "end_tip_pick_up_process")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+
+    return self.send_command(
+      module="C0",
+      command="TP",
+      timeout=60,
+      xp=[f"{x:05}" for x in x_positions],
+      yp=[f"{y:04}" for y in y_positions],
+      tm=tip_pattern,
+      tt=f"{tip_type:02}",
+      tp=f"{begin_tip_pick_up_process:04}",
+      tz=f"{end_tip_pick_up_process:04}",
+      th=f"{minimum_traverse_height_at_beginning_of_a_command:04}",
+      td=pick_up_method,
+    )
+
+  class DiscardingMethod(enum.Enum):
+    """ Tip discarding method """
+    PLACE_SHIFT = 0
+    DROP = 1
+
+  def discard_tip(
+    self,
+    x_positions: int = 0, # TODO: these are probably lists.
+    y_positions: int = 0, # TODO: these are probably lists.
+    tip_pattern: bool = True,
+    tip_type: FirmwareTipType = FirmwareTipType.STANDARD_VOLUME,
+    begin_tip_deposit_process: int = 0,
+    end_tip_deposit_process: int = None,
+    minimum_traverse_height_at_beginning_of_a_command: int = None,
+    discarding_method: DiscardingMethod = DiscardingMethod.DROP
+  ):
+    """ discard tip
+
+    Args:
+      x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0.
+      y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0.
+      tip_pattern: Tip pattern (channels involved). Must be between 0 and 1. Default 1.
+      tip_type: Tip type. Must be between 0 and 99. Default 4.
+      begin_tip_deposit_process: Begin of tip deposit process (Z- range) [0.1mm]. Must be between
+          0 and 3600. Default 0.
+      end_tip_deposit_process: End of tip deposit process (Z- range) [0.1mm]. Must be between 0
+          and 3600.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a
+          command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must
+          be between 0 and 3600.
+      discarding_method: Pick up method Pick up method. 0 = auto selection (see command TT
+          parameter tu) 1 = pick up out of rack. 2 = pick up out of wash liquid (slowly). Must be
+          between 0 and 2.
+
+    If discarding is PLACE_SHIFT (0), tp/ tz = tip cone end height.
+    Otherwise, tp/ tz = stop disk height.
+    """
+
+    utils.assert_clamp(x_positions, 0, 25000, "x_positions")
+    utils.assert_clamp(y_positions, 0, 6500, "y_positions")
+    utils.assert_clamp(begin_tip_deposit_process, 0, 3600, "begin_tip_deposit_process")
+    utils.assert_clamp(end_tip_deposit_process, 0, 3600, "end_tip_deposit_process")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+
+    return self.send_command(
+      module="C0",
+      command="TR",
+      xp=[f"{x:05}" for x in x_positions],
+      yp=[f"{y:04}" for y in y_positions],
+      tm=tip_pattern,
+      tt=f"{tip_type:02}",
+      tp=begin_tip_deposit_process,
+      tz=end_tip_deposit_process,
+      th=minimum_traverse_height_at_beginning_of_a_command,
+      ti=discarding_method,
+    )
+
+  # TODO:(command:TW) Tip Pick-up for DC wash procedure
+
+  # -------------- 3.5.3 Liquid handling commands using PIP --------------
+
+  # TODO:(command:DC) Set multiple dispense values using PIP
+
+  def aspirate_pip(
+    self,
+    aspiration_type: typing.List[int] = [0],
+    tip_pattern: typing.List[bool] = [True],
+    x_positions: typing.List[int] = [0],
+    y_positions: typing.List[int] = [0],
+    minimum_traverse_height_at_beginning_of_a_command: int = 3600,
+    min_z_endpos: int = 3600,
+    lld_search_height: typing.List[int] = [0],
+    clot_detection_height: typing.List[int] = [60],
+    liquid_surface_no_lld: typing.List[int] = [3600],
+    pull_out_distance_transport_air: typing.List[int] = [50],
+    second_section_height: typing.List[int] = [0],
+    second_section_ratio: typing.List[int] = [0],
+    minimum_height: typing.List[int] = [3600],
+    immersion_depth: typing.List[int] = [0],
+    immersion_depth_direction: typing.List[int] = [0],
+    surface_following_distance: typing.List[int] = [0],
+    aspiration_volumes: typing.List[int] = [0],
+    aspiration_speed: typing.List[int] = [500],
+    transport_air_volume: typing.List[int] = [0],
+    blow_out_air_volume: typing.List[int] = [200],
+    pre_wetting_volume: typing.List[int] = [0],
+    lld_mode: typing.List[int] = [1],
+    gamma_lld_sensitivity: typing.List[int] = [1],
+    dp_lld_sensitivity: typing.List[int] = [1],
+    aspirate_position_above_z_touch_off: typing.List[int] = [5],
+    detection_height_difference_for_dual_lld: typing.List[int] = [0],
+    swap_speed: typing.List[int] = [100],
+    settling_time: typing.List[int] = [5],
+    homogenization_volume: typing.List[int] = [0],
+    homogenization_cycles: typing.List[int] = [0],
+    homogenization_position_from_liquid_surface: typing.List[int] = [250],
+    homogenization_speed: typing.List[int] = [500],
+    homogenization_surface_following_distance: typing.List[int] = [0],
+    limit_curve_index: typing.List[int] = [0],
+    tadm_algorithm: bool = False,
+    recording_mode: int = 0,
+
+    # For second section aspiration only
+    use_2nd_section_aspiration: typing.List[bool] = [False],
+    retract_height_over_2nd_section_to_empty_tip: typing.List[int] = [60],
+    dispensation_speed_during_emptying_tip: typing.List[int] = [468],
+    dosing_drive_speed_during_2nd_section_search: typing.List[int] = [468],
+    z_drive_speed_during_2nd_section_search: typing.List[int] = [215],
+    cup_upper_edge: typing.List[int] = [3600],
+    ratio_liquid_rise_to_tip_deep_in: typing.List[int] = [16246],
+    immersion_depth_2nd_section: typing.List[int] = [30]
+  ):
+    """ aspirate pip
+
+    Aspiration of liquid using PIP.
+
+    It's not really clear what second section aspiration is, but it does not seem to be used
+    very often. Probably safe to ignore it.
+
+    LLD restrictions!
+      - "dP and Dual LLD" are used in aspiration only. During dispensation LLD is set to OFF.
+      - "side touch off" turns LLD & "Z touch off" to OFF , is not available for simultaneous
+        Asp/Disp. command
+
+    Args:
+      aspiration_type: Type of aspiration (0 = simple;1 = sequence; 2 = cup emptied).
+                        Must be between 0 and 2. Default 0.
+      tip_pattern: Tip pattern (channels involved). Default True.
+      x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0.
+      y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of
+          a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm').
+          Must be between 0 and 3600. Default 3600.
+      min_z_endpos: Minimum z-Position at end of a command [0.1 mm] (refers to all channels
+          independent of tip pattern parameter 'tm'). Must be between 0 and 3600. Default 3600.
+      lld_search_height: LLD search height [0.1 mm]. Must be between 0 and 3600. Default 0.
+      clot_detection_height: Check height of clot detection above current surface (as computed)
+          of the liquid [0.1mm]. Must be between 0 and 500. Default 60.
+      liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0
+          and 3600. Default 3600.
+      pull_out_distance_transport_air: pull out distance to take transport air in function
+          without LLD [0.1mm]. Must be between 0 and 3600. Default 50.
+      second_section_height: Tube 2nd section height measured from "zx" [0.1mm]. Must be
+          between 0 and 3600. Default 0.
+      second_section_ratio: Tube 2nd section ratio (see Fig. 2 in fw guide). Must be between
+          0 and 10000. Default 0.
+      minimum_height: Minimum height (maximum immersion depth) [0.1 mm]. Must be between 0 and
+          3600. Default 3600.
+      immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0.
+      immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out
+          of liquid). Must be between 0 and 1. Default 0.
+      surface_following_distance: Surface following distance during aspiration [0.1mm]. Must
+          be between 0 and 3600. Default 0.
+      aspiration_volumes: Aspiration volume [0.1ul]. Must be between 0 and 12500. Default 0.
+      aspiration_speed: Aspiration speed [0.1ul/s]. Must be between 4 and 5000. Default 500.
+      transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0.
+      blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 9999. Default 200.
+      pre_wetting_volume: Pre-wetting volume. Must be between 0 and 999. Default 0.
+      lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be
+            between 0 and 4. Default 1.
+      gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and
+            4. Default 1.
+      dp_lld_sensitivity: delta p LLD sensitivity (1= high, 4=low). Must be between 1 and
+            4. Default 1.
+      aspirate_position_above_z_touch_off: aspirate position above Z touch off [0.1mm]. Must
+            be between 0 and 100. Default 5.
+      detection_height_difference_for_dual_lld: Difference in detection height for dual
+            LLD [0.1 mm]. Must be between 0 and 99. Default 0.
+      swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600.
+            Default 100.
+      settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5.
+      homogenization_volume: Homogenization volume [0.1ul]. Must be between 0 and 12500. Default 0
+      homogenization_cycles: Number of homogenization cycles. Must be between 0 and 99. Default 0.
+      homogenization_position_from_liquid_surface: Homogenization position in Z- direction from
+          liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 900. Default 250.
+      homogenization_speed: Speed of homogenization [0.1ul/s]. Must be between 4 and 5000.
+          Default 500.
+      homogenization_surface_following_distance: Surface following distance during
+          homogenization [0.1mm]. Must be between 0 and 3600. Default 0.
+      limit_curve_index: limit curve index. Must be between 0 and 999. Default 0.
+      tadm_algorithm: TADM algorithm. Default False.
+      recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must
+          be between 0 and 2. Default 0.
+      use_2nd_section_aspiration: 2nd section aspiration. Default False.
+      retract_height_over_2nd_section_to_empty_tip: Retract height over 2nd section to empty
+          tip [0.1mm]. Must be between 0 and 3600. Default 60.
+      dispensation_speed_during_emptying_tip: Dispensation speed during emptying tip [0.1ul/s]
+            Must be between 4 and 5000. Default 468.
+      dosing_drive_speed_during_2nd_section_search: Dosing drive speed during 2nd section
+          search [0.1ul/s]. Must be between 4 and 5000. Default 468.
+      z_drive_speed_during_2nd_section_search: Z drive speed during 2nd section search [0.1mm/s].
+          Must be between 3 and 1600. Default 215.
+      cup_upper_edge: Cup upper edge [0.1mm]. Must be between 0 and 3600. Default 3600.
+      ratio_liquid_rise_to_tip_deep_in: Ratio liquid rise to tip deep in [1/100000]. Must be
+          between 0 and 50000. Default 16246.
+      immersion_depth_2nd_section: Immersion depth 2nd section [0.1mm]. Must be between 0 and
+          3600. Default 30.
+    """
+
+    utils.assert_clamp(aspiration_type, 0, 2, "aspiration_type")
+    utils.assert_clamp(x_positions, 0, 25000, "x_positions")
+    utils.assert_clamp(y_positions, 0, 6500, "y_positions")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+    utils.assert_clamp(min_z_endpos, 0, 3600, "min_z_endpos")
+    utils.assert_clamp(lld_search_height, 0, 3600, "lld_search_height")
+    utils.assert_clamp(clot_detection_height, 0, 500, "clot_detection_height")
+    utils.assert_clamp(liquid_surface_no_lld, 0, 3600, "liquid_surface_no_lld")
+    utils.assert_clamp(pull_out_distance_transport_air, 0, 3600, "pull_out_distance_transport_air")
+    utils.assert_clamp(second_section_height, 0, 3600, "second_section_height")
+    utils.assert_clamp(second_section_ratio, 0, 10000, "second_section_ratio")
+    utils.assert_clamp(minimum_height, 0, 3600, "minimum_height")
+    utils.assert_clamp(immersion_depth, 0, 3600, "immersion_depth")
+    utils.assert_clamp(immersion_depth_direction, 0, 1, "immersion_depth_direction")
+    utils.assert_clamp(surface_following_distance, 0, 3600, "surface_following_distance")
+    utils.assert_clamp(aspiration_volumes, 0, 12500, "aspiration_volumes")
+    utils.assert_clamp(aspiration_speed, 4, 5000, "aspiration_speed")
+    utils.assert_clamp(transport_air_volume, 0, 500, "transport_air_volume")
+    utils.assert_clamp(blow_out_air_volume, 0, 9999, "blow_out_air_volume")
+    utils.assert_clamp(pre_wetting_volume, 0, 999, "pre_wetting_volume")
+    utils.assert_clamp(lld_mode, 0, 4, "lld_mode")
+    utils.assert_clamp(gamma_lld_sensitivity, 1, 4, "gamma_lld_sensitivity")
+    utils.assert_clamp(dp_lld_sensitivity, 1, 4, "dp_lld_sensitivity")
+    utils.assert_clamp(aspirate_position_above_z_touch_off, 0, 100, \
+                  "aspirate_position_above_z_touch_off")
+    utils.assert_clamp(detection_height_difference_for_dual_lld, 0, 99, \
+                  "detection_height_difference_for_dual_lld")
+    utils.assert_clamp(swap_speed, 3, 1600, "swap_speed")
+    utils.assert_clamp(settling_time, 0, 99, "settling_time")
+    utils.assert_clamp(homogenization_volume, 0, 12500, "homogenization_volume")
+    utils.assert_clamp(homogenization_cycles, 0, 99, "homogenization_cycles")
+    utils.assert_clamp(homogenization_position_from_liquid_surface, 0, 900, \
+                  "homogenization_position_from_liquid_surface")
+    utils.assert_clamp(homogenization_speed, 4, 5000, "homogenization_speed")
+    utils.assert_clamp(homogenization_surface_following_distance, 0, 3600, \
+                  "homogenization_surface_following_distance")
+    utils.assert_clamp(limit_curve_index, 0, 999, "limit_curve_index")
+    utils.assert_clamp(recording_mode, 0, 2, "recording_mode")
+    utils.assert_clamp(retract_height_over_2nd_section_to_empty_tip, 0, 3600, \
+                  "retract_height_over_2nd_section_to_empty_tip")
+    utils.assert_clamp(dispensation_speed_during_emptying_tip, 4, 5000, \
+                  "dispensation_speed_during_emptying_tip")
+    utils.assert_clamp(dosing_drive_speed_during_2nd_section_search, 4, 5000, \
+                  "dosing_drive_speed_during_2nd_section_search")
+    utils.assert_clamp(z_drive_speed_during_2nd_section_search, 3, 1600, \
+                  "z_drive_speed_during_2nd_section_search")
+    utils.assert_clamp(cup_upper_edge, 0, 3600, "cup_upper_edge")
+    utils.assert_clamp(ratio_liquid_rise_to_tip_deep_in, 0, 50000, "ratio_liquid_rise_to_tip_deep_in")
+    utils.assert_clamp(immersion_depth_2nd_section, 0, 3600, "immersion_depth_2nd_section")
+
+    resp = self.send_command(
+      module="C0",
+      command="AS",
+      timeout=60,
+      at=[f"{at:01}" for at in aspiration_type],
+      tm=tip_pattern,
+      xp=[f"{xp:05}" for xp in x_positions],
+      yp=[f"{yp:04}" for yp in y_positions],
+      th=f"{minimum_traverse_height_at_beginning_of_a_command:04}",
+      te=f"{min_z_endpos:04}",
+      lp=[f"{lp:04}" for lp in lld_search_height],
+      ch=[f"{ch:03}" for ch in clot_detection_height],
+      zl=[f"{zl:04}" for zl in liquid_surface_no_lld],
+      po=[f"{po:04}" for po in pull_out_distance_transport_air],
+      zu=[f"{zu:04}" for zu in second_section_height],
+      zr=[f"{zr:05}" for zr in second_section_ratio],
+      zx=[f"{zx:04}" for zx in minimum_height],
+      ip=[f"{ip:04}" for ip in immersion_depth],
+      it=[f"{it}"    for it in immersion_depth_direction],
+      fp=[f"{fp:04}" for fp in surface_following_distance],
+      av=[f"{av:05}" for av in aspiration_volumes],
+      as_=[f"{as_:04}" for as_ in aspiration_speed],
+      ta=[f"{ta:03}" for ta in transport_air_volume],
+      ba=[f"{ba:04}" for ba in blow_out_air_volume],
+      oa=[f"{oa:03}" for oa in pre_wetting_volume],
+      lm=[f"{lm}"    for lm in lld_mode],
+      ll=[f"{ll}"    for ll in gamma_lld_sensitivity],
+      lv=[f"{lv}"    for lv in dp_lld_sensitivity],
+      zo=[f"{zo:03}" for zo in aspirate_position_above_z_touch_off],
+      ld=[f"{ld:02}" for ld in detection_height_difference_for_dual_lld],
+      de=[f"{de:04}" for de in swap_speed],
+      wt=[f"{wt:02}" for wt in settling_time],
+      mv=[f"{mv:05}" for mv in homogenization_volume],
+      mc=[f"{mc:02}" for mc in homogenization_cycles],
+      mp=[f"{mp:03}" for mp in homogenization_position_from_liquid_surface],
+      ms=[f"{ms:04}" for ms in homogenization_speed],
+      mh=[f"{mh:04}" for mh in homogenization_surface_following_distance],
+      gi=[f"{gi:03}" for gi in limit_curve_index],
+      gj=tadm_algorithm,
+      gk=recording_mode,
+
+      lk=[1 if lk else 0 for lk in use_2nd_section_aspiration],
+      ik=[f"{ik:04}" for ik in retract_height_over_2nd_section_to_empty_tip],
+      sd=[f"{sd:04}" for sd in dispensation_speed_during_emptying_tip],
+      se=[f"{se:04}" for se in dosing_drive_speed_during_2nd_section_search],
+      sz=[f"{sz:04}" for sz in z_drive_speed_during_2nd_section_search],
+      io=[f"{io:04}" for io in cup_upper_edge],
+      il=[f"{il:05}" for il in ratio_liquid_rise_to_tip_deep_in],
+      in_=[f"{in_:04}" for in_ in immersion_depth_2nd_section],
+    )
+    return resp
+
+  def dispense_pip(
+    self,
+    dispensing_mode: typing.List[int] = [0],
+    tip_pattern: typing.List[bool] = True,
+    x_positions: typing.List[int] = [0],
+    y_positions: typing.List[int] = [0],
+    minimum_height: typing.List[int] = [3600],
+    lld_search_height: typing.List[int] = [0],
+    liquid_surface_no_lld: typing.List[int] = [3600],
+    pull_out_distance_transport_air: typing.List[int] = [50],
+    immersion_depth: typing.List[int] = [0],
+    immersion_depth_direction: typing.List[int] = [0],
+    surface_following_distance: typing.List[int] = [0],
+    second_section_height: typing.List[int] = [0],
+    second_section_ratio: typing.List[int] = [0],
+    minimum_traverse_height_at_beginning_of_a_command: int = 3600,
+    min_z_endpos: typing.List[int] = [3600], #
+    dispense_volumes: typing.List[int] = [0],
+    dispense_speed: typing.List[int] = [500],
+    cut_off_speed: typing.List[int] = [250],
+    stop_back_volume: typing.List[int] = [0],
+    transport_air_volume: typing.List[int] = [0],
+    blow_out_air_volume: typing.List[int] = [200],
+    lld_mode: typing.List[int] = [1],
+    side_touch_off_distance: int = 1,
+    dispense_position_above_z_touch_off: typing.List[int] = [5],
+    gamma_lld_sensitivity: typing.List[int] = [1],
+    dp_lld_sensitivity: typing.List[int] = [1],
+    swap_speed: typing.List[int] = [100],
+    settling_time: typing.List[int] = [5],
+    mix_volume: typing.List[int] = [0],
+    mix_cycles: typing.List[int] = [0],
+    mix_position_from_liquid_surface: typing.List[int] = [250],
+    mix_speed: typing.List[int] = [500],
+    mix_surface_following_distance: typing.List[int] = [0],
+    limit_curve_index: typing.List[int] = [0],
+    tadm_algorithm: bool = False,
+    recording_mode: int = 0
+  ):
+    """ dispense pip
+
+    Dispensing of liquid using PIP.
+
+    LLD restrictions!
+      - "dP and Dual LLD" are used in aspiration only. During dispensation LLD is set to OFF.
+      - "side touch off" turns LLD & "Z touch off" to OFF , is not available for simultaneous
+        Asp/Disp. command
+
+    Args:
+      dispensing_mode: Type of dispensing mode 0 = Partial volume in jet mode
+        1 = Blow out in jet mode 2 = Partial volume at surface
+        3 = Blow out at surface 4 = Empty tip at fix position.
+      tip_pattern: Tip pattern (channels involved). Default True.
+      x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0.
+      y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0.
+      minimum_height: Minimum height (maximum immersion depth) [0.1 mm]. Must be between 0 and
+        3600. Default 3600.
+      lld_search_height: LLD search height [0.1 mm]. Must be between 0 and 3600. Default 0.
+      liquid_surface_no_lld: Liquid surface at function without LLD [0.1mm]. Must be between 0 and
+        3600. Default 3600.
+      pull_out_distance_transport_air: pull out distance to take transport air in function without
+        LLD [0.1mm]. Must be between 0 and 3600. Default 50.
+      immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0.
+      immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of
+        liquid). Must be between 0 and 1. Default 0.
+      surface_following_distance: Surface following distance during aspiration [0.1mm]. Must be
+        between 0 and 3600. Default 0.
+      second_section_height: Tube 2nd section height measured from "zx" [0.1mm]. Must be between
+        0 and 3600. Default 0.
+      second_section_ratio: Tube 2nd section ratio (see Fig. 2 in fw guide). Must be between 0 and
+        10000. Default 0.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a
+        command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be
+        between 0 and 3600. Default 3600.
+      min_z_endpos: Minimum z-Position at end of a command [0.1 mm] (refers to all channels
+        independent of tip pattern parameter 'tm'). Must be between 0 and 3600.  Default 3600.
+      dispense_volumes: Dispense volume [0.1ul]. Must be between 0 and 12500. Default 0.
+      dispense_speed: Dispense speed [0.1ul/s]. Must be between 4 and 5000. Default 500.
+      cut_off_speed: Cut-off speed [0.1ul/s]. Must be between 4 and 5000. Default 250.
+      stop_back_volume: Stop back volume [0.1ul]. Must be between 0 and 180. Default 0.
+      transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0.
+      blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 9999. Default 200.
+      lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be between 0
+        and 4. Default 1.
+      side_touch_off_distance: side touch off distance [0.1 mm] (0 = OFF). Must be between 0 and 45.
+        Default 1.
+      dispense_position_above_z_touch_off: dispense position above Z touch off [0.1 s] (0 = OFF)
+        Turns LLD & Z touch off to OFF if ON!. Must be between 0 and 100. Default 5.
+      gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4.
+        Default 1.
+      dp_lld_sensitivity: delta p LLD sensitivity (1= high, 4=low). Must be between 1 and 4.
+        Default 1.
+      swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1600.
+        Default 100.
+      settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5.
+      mix_volume: Mix volume [0.1ul]. Must be between 0 and 12500. Default 0.
+      mix_cycles: Number of mix cycles. Must be between 0 and 99. Default 0.
+      mix_position_from_liquid_surface: Mix position in Z- direction from liquid surface (LLD or
+        absolute terms) [0.1mm]. Must be between 0 and 900.  Default 250.
+      mix_speed: Speed of mixing [0.1ul/s]. Must be between 4 and 5000. Default 500.
+      mix_surface_following_distance: Surface following distance during mixing [0.1mm]. Must be
+        between 0 and 3600. Default 0.
+      limit_curve_index: limit curve index. Must be between 0 and 999. Default 0.
+      tadm_algorithm: TADM algorithm. Default False.
+      recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must
+        be between 0 and 2. Default 0.
+    """
+
+    utils.assert_clamp(dispensing_mode, 0, 4, "dispensing_mode")
+    utils.assert_clamp(x_positions, 0, 25000, "x_positions")
+    utils.assert_clamp(y_positions, 0, 6500, "y_positions")
+    utils.assert_clamp(minimum_height, 0, 3600, "minimum_height")
+    utils.assert_clamp(lld_search_height, 0, 3600, "lld_search_height")
+    utils.assert_clamp(liquid_surface_no_lld, 0, 3600, "liquid_surface_no_lld")
+    utils.assert_clamp(pull_out_distance_transport_air, 0, 3600, "pull_out_distance_transport_air")
+    utils.assert_clamp(immersion_depth, 0, 3600, "immersion_depth")
+    utils.assert_clamp(immersion_depth_direction, 0, 1, "immersion_depth_direction")
+    utils.assert_clamp(surface_following_distance, 0, 3600, "surface_following_distance")
+    utils.assert_clamp(second_section_height, 0, 3600, "second_section_height")
+    utils.assert_clamp(second_section_ratio, 0, 10000, "second_section_ratio")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+    utils.assert_clamp(min_z_endpos, 0, 3600, "min_z_endpos")
+    utils.assert_clamp(dispense_volumes, 0, 12500, "dispense_volume")
+    utils.assert_clamp(dispense_speed, 4, 5000, "dispense_speed")
+    utils.assert_clamp(cut_off_speed, 4, 5000, "cut_off_speed")
+    utils.assert_clamp(stop_back_volume, 0, 180, "stop_back_volume")
+    utils.assert_clamp(transport_air_volume, 0, 500, "transport_air_volume")
+    utils.assert_clamp(blow_out_air_volume, 0, 9999, "blow_out_air_volume")
+    utils.assert_clamp(lld_mode, 0, 4, "lld_mode")
+    utils.assert_clamp(side_touch_off_distance, 0, 45, "side_touch_off_distance")
+    utils.assert_clamp(dispense_position_above_z_touch_off, 0, 100, \
+                  "dispense_position_above_z_touch_off")
+    utils.assert_clamp(gamma_lld_sensitivity, 1, 4, "gamma_lld_sensitivity")
+    utils.assert_clamp(dp_lld_sensitivity, 1, 4, "dp_lld_sensitivity")
+    utils.assert_clamp(swap_speed, 3, 1600, "swap_speed")
+    utils.assert_clamp(settling_time, 0, 99, "settling_time")
+    utils.assert_clamp(mix_volume, 0, 12500, "mix_volume")
+    utils.assert_clamp(mix_cycles, 0, 99, "mix_cycles")
+    utils.assert_clamp(mix_position_from_liquid_surface, 0, 900, "mix_position_from_liquid_surface")
+    utils.assert_clamp(mix_speed, 4, 5000, "mix_speed")
+    utils.assert_clamp(mix_surface_following_distance, 0, 3600, "mix_surface_following_distance")
+    utils.assert_clamp(limit_curve_index, 0, 999, "limit_curve_index")
+    utils.assert_clamp(recording_mode, 0, 2, "recording_mode")
+
+    return self.send_command(
+      module="C0",
+      command="DS",
+      timeout=60,
+      dm=[f"{dm:01}" for dm in dispensing_mode],
+      tm=[f"{tm:01}" for tm in tip_pattern],
+      xp=[f"{xp:05}" for xp in x_positions],
+      yp=[f"{yp:04}" for yp in y_positions],
+      zx=[f"{zx:04}" for zx in minimum_height],
+      lp=[f"{lp:04}" for lp in lld_search_height],
+      zl=[f"{zl:04}" for zl in liquid_surface_no_lld],
+      po=[f"{po:04}" for po in pull_out_distance_transport_air],
+      ip=[f"{ip:04}" for ip in immersion_depth],
+      it=[f"{it:01}" for it in immersion_depth_direction],
+      fp=[f"{fp:04}" for fp in surface_following_distance],
+      zu=[f"{zu:04}" for zu in second_section_height],
+      zr=[f"{zr:05}" for zr in second_section_ratio],
+      th=f"{minimum_traverse_height_at_beginning_of_a_command:04}",
+      te=f"{min_z_endpos:04}",
+      dv=[f"{dv:05}" for dv in dispense_volumes],
+      ds=[f"{ds:04}" for ds in dispense_speed],
+      ss=[f"{ss:04}" for ss in cut_off_speed],
+      rv=[f"{rv:03}" for rv in stop_back_volume],
+      ta=[f"{ta:03}" for ta in transport_air_volume],
+      ba=[f"{ba:04}" for ba in blow_out_air_volume],
+      lm=[f"{lm:01}" for lm in lld_mode],
+      dj=f"{side_touch_off_distance:02}", #
+      zo=[f"{zo:03}" for zo in dispense_position_above_z_touch_off],
+      ll=[f"{ll:01}" for ll in gamma_lld_sensitivity],
+      lv=[f"{lv:01}" for lv in dp_lld_sensitivity],
+      de=[f"{de:04}" for de in swap_speed],
+      wt=[f"{wt:02}" for wt in settling_time],
+      mv=[f"{mv:05}" for mv in mix_volume],
+      mc=[f"{mc:02}" for mc in mix_cycles],
+      mp=[f"{mp:03}" for mp in mix_position_from_liquid_surface],
+      ms=[f"{ms:04}" for ms in mix_speed],
+      mh=[f"{mh:04}" for mh in mix_surface_following_distance],
+      gi=[f"{gi:03}" for gi in limit_curve_index],
+      gj=tadm_algorithm, #
+      gk=recording_mode, #
+    )
+
+  # TODO:(command:DA) Simultaneous aspiration & dispensation of liquid
+
+  # TODO:(command:DF) Dispense on fly using PIP (Partial volume in jet mode)
+
+  # TODO:(command:LW) DC Wash procedure using PIP
+
+  # -------------- 3.5.5 CoRe gripper commands --------------
+
+  # TODO:(command) All CoRe gripper commands
+  # TODO:(command:ZT)
+  # TODO:(command:ZS)
+  # TODO:(command:ZP)
+  # TODO:(command:ZR)
+  # TODO:(command:ZM)
+  # TODO:(command:ZO)
+  # TODO:(command:ZB)
+
+  # -------------- 3.5.6 Adjustment & movement commands --------------
+
+  # TODO:(command:JY) Position all pipetting channels in Y-direction
+
+  # TODO:(command:JZ) Position all pipetting channels in Z-direction
+
+  def position_single_pipetting_channel_in_y_direction(
+    self,
+    pipetting_channel_index: int = 1,
+    y_position: int = 0
+  ):
+    """ Position single pipetting channel in Y-direction
+
+    Args:
+      pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. Default 1.
+      y_position: y position [0.1mm]. Must be between 0 and 6500. Default 0.
+    """
+
+    utils.assert_clamp(pipetting_channel_index, 1, 16, "pipetting_channel_index")
+    utils.assert_clamp(y_position, 0, 6500, "y_position")
+
+    return self.send_command(
+      module="C0",
+      command="KY",
+      pn=pipetting_channel_index,
+      yj=y_position,
+    )
+
+  def position_single_pipetting_channel_in_z_direction(
+    self,
+    pipetting_channel_index: int = 1,
+    z_position: int = 0
+  ):
+    """ Position single pipetting channel in Z-direction
+
+    Args:
+      pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. Default 1.
+      z_position: y position [0.1mm]. Must be between 0 and 6500. Default 0.
+    """
+
+    utils.assert_clamp(pipetting_channel_index, 1, 16, "pipetting_channel_index")
+    utils.assert_clamp(z_position, 0, 6500, "z_position")
+
+    return self.send_command(
+      module="C0",
+      command="KZ",
+      pn=pipetting_channel_index,
+      zp=z_position,
+    )
+
+  # TODO:(command:XL) Search for Teach in signal using pipetting channel n in X-direction
+
+  def spread_pip_channels(self):
+    """ Spread PIP channels """
+
+    resp = self.send_command(module="C0", command="JE")
+    return self.parse_response(resp, "") # TODO: what does `( Pn##/##)` response mean?
+
+  def move_all_pipetting_channels_to_defined_position(
+    self,
+    tip_pattern: bool = True,
+    x_positions: int = 0,
+    y_positions: int = 0,
+    minimum_traverse_height_at_beginning_of_command: int = 3600,
+    z_endpos: int = 0
+  ):
+    """ Move all pipetting channels to defined position
+
+    Args:
+      tip_pattern: Tip pattern (channels involved). Default True.
+      x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0.
+      y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0.
+      minimum_traverse_height_at_beginning_of_command: Minimum traverse height at beginning of a
+                                                       command 0.1mm] (refers to all channels
+                                                       independent of tip pattern parameter 'tm').
+                                                       Must be between 0 and 3600. Default 3600.
+      z_endpos: Z-Position at end of a command [0.1 mm] (refers to all channels independent of tip
+                pattern parameter 'tm'). Must be between 0 and 3600. Default 0.
+    """
+
+    utils.assert_clamp(x_positions, 0, 25000, "x_positions")
+    utils.assert_clamp(y_positions, 0, 6500, "y_positions")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_command")
+    utils.assert_clamp(z_endpos, 0, 3600, "z_endpos")
+
+    return self.send_command(
+      module="C0",
+      command="JM",
+      tm=tip_pattern,
+      xp=x_positions,
+      yp=y_positions,
+      th=minimum_traverse_height_at_beginning_of_command,
+      zp=z_endpos,
+    )
+
+  # TODO:(command:JR): teach rack using pipetting channel n
+
+  def position_max_free_y_for_n(
+    self,
+    pipetting_channel_index: int = 1
+  ):
+    """ Position all pipetting channels so that there is maximum free Y range for channel n
+
+    Args:
+      pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. Default 1.
+    """
+
+    utils.assert_clamp(pipetting_channel_index, 1, 16, "pipetting_channel_index")
+
+    return self.send_command(
+      module="C0",
+      command="KZ",
+      pn=pipetting_channel_index,
+    )
+
+  def move_all_channels_in_z_safety(self):
+    """ Move all pipetting channels in Z-safety position """
+
+    resp = self.send_command(module="C0", command="ZA")
+    return self.parse_response(resp, "")
+
+  # -------------- 3.5.7 PIP query --------------
+
+  # TODO:(command:RY): Request Y-Positions of all pipetting channels
+
+  def request_y_pos_channel_n(
+    self,
+    pipetting_channel_index: int = 1
+  ):
+    """ Request Y-Position of Pipetting channel n
+
+    Args:
+      pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. Default 1.
+    """
+
+    utils.assert_clamp(pipetting_channel_index, 1, 16, "pipetting_channel_index")
+
+    resp = self.send_command(
+      module="C0",
+      command="RB",
+      pn=pipetting_channel_index,
+    )
+    return self.parse_response(resp, "rb####")
+
+  # TODO:(command:RZ): Request Z-Positions of all pipetting channels
+
+  def request_z_pos_channel_n(
+    self,
+    pipetting_channel_index: int = 1
+  ):
+    """ Request Z-Position of Pipetting channel n
+
+    Args:
+      pipetting_channel_index: Index of pipetting channel. Must be between 1 and 16. Default 1.
+
+    Returns:
+      Z-Position of channel n [0.1mm]. Taking into account tip presence and length.
+    """
+
+    utils.assert_clamp(pipetting_channel_index, 1, 16, "pipetting_channel_index")
+
+    resp = self.send_command(
+      module="C0",
+      command="RD",
+      pn=pipetting_channel_index,
+    )
+    return self.parse_response(resp, "rd####")
+
+  def request_tip_presence(self):
+    """ Request query tip presence on each channel
+
+    Returns:
+      0 = no tip, 1 = Tip in gripper (for each channel)
+    """
+
+    resp = self.send_command(module="C0", command="RT")
+    return self.parse_response(resp, "rt# (n)")
+
+  def request_pip_height_last_lld(self):
+    """ Request PIP height of last LLD
+
+    Returns:
+      LLD height of all channels
+    """
+
+    resp = self.send_command(module="C0", command="RL")
+    return self.parse_response(resp, "lh#### (n)")
+
+  def request_tadm_status(self):
+    """ Request PIP height of last LLD
+
+    Returns:
+      TADM channel status 0 = off, 1 = on
+    """
+
+    resp = self.send_command(module="C0", command="QS")
+    return self.parse_response(resp, "qs# (n)")
+
+  # TODO:(command:FS) Request PIP channel dispense on fly status
+  # TODO:(command:VE) Request PIP channel 2nd section aspiration data
+
+  # -------------- 3.6 XL channel commands --------------
+
+  # TODO: all XL channel commands
+
+  # -------------- 3.6.1 Initialization XL --------------
+
+  # TODO:(command:LI)
+
+  # -------------- 3.6.2 Tip handling commands using XL --------------
+
+  # TODO:(command:LP)
+  # TODO:(command:LR)
+
+  # -------------- 3.6.3 Liquid handling commands using XL --------------
+
+  # TODO:(command:LA)
+  # TODO:(command:LD)
+  # TODO:(command:LB)
+  # TODO:(command:LC)
+
+  # -------------- 3.6.4 Wash commands using XL channel --------------
+
+  # TODO:(command:LE)
+  # TODO:(command:LF)
+
+  # -------------- 3.6.5 XL CoRe gripper commands --------------
+
+  # TODO:(command:LT)
+  # TODO:(command:LS)
+  # TODO:(command:LU)
+  # TODO:(command:LV)
+  # TODO:(command:LM)
+  # TODO:(command:LO)
+  # TODO:(command:LG)
+
+  # -------------- 3.6.6 Adjustment & movement commands CP --------------
+
+  # TODO:(command:LY)
+  # TODO:(command:LZ)
+  # TODO:(command:LH)
+  # TODO:(command:LJ)
+  # TODO:(command:XM)
+  # TODO:(command:LL)
+  # TODO:(command:LQ)
+  # TODO:(command:LK)
+  # TODO:(command:UE)
+
+  # -------------- 3.6.7 XL channel query --------------
+
+  # TODO:(command:UY)
+  # TODO:(command:UB)
+  # TODO:(command:UZ)
+  # TODO:(command:UD)
+  # TODO:(command:UT)
+  # TODO:(command:UL)
+  # TODO:(command:US)
+  # TODO:(command:UF)
+
+  # -------------- 3.7 Tube gripper commands --------------
+
+  # TODO: all tube gripper commands
+
+  # -------------- 3.7.1 Movements --------------
+
+  # TODO:(command:FC)
+  # TODO:(command:FD)
+  # TODO:(command:FO)
+  # TODO:(command:FT)
+  # TODO:(command:FU)
+  # TODO:(command:FJ)
+  # TODO:(command:FM)
+  # TODO:(command:FW)
+
+  # -------------- 3.7.2 Tube gripper query --------------
+
+  # TODO:(command:FQ)
+  # TODO:(command:FN)
+
+  # -------------- 3.8 Imaging channel commands --------------
+
+  # TODO: all imaging commands
+
+  # -------------- 3.8.1 Movements --------------
+
+  # TODO:(command:IC)
+  # TODO:(command:ID)
+  # TODO:(command:IM)
+  # TODO:(command:IJ)
+
+  # -------------- 3.8.2 Imaging channel query --------------
+
+  # TODO:(command:IN)
+
+  # -------------- 3.9 Robotic channel commands --------------
+
+  # -------------- 3.9.1 Initialization --------------
+
+  # TODO:(command:OI)
+
+  # -------------- 3.9.2 Cap handling commands --------------
+
+  # TODO:(command:OP)
+  # TODO:(command:OQ)
+
+  # -------------- 3.9.3 Adjustment & movement commands --------------
+
+  # TODO:(command:OY)
+  # TODO:(command:OZ)
+  # TODO:(command:OH)
+  # TODO:(command:OJ)
+  # TODO:(command:OX)
+  # TODO:(command:OM)
+  # TODO:(command:OF)
+  # TODO:(command:OG)
+
+  # -------------- 3.9.4 Robotic channel query --------------
+
+  # TODO:(command:OA)
+  # TODO:(command:OB)
+  # TODO:(command:OC)
+  # TODO:(command:OD)
+  # TODO:(command:OT)
+
+  # -------------- 3.10 CoRe 96 Head commands --------------
+
+  # -------------- 3.10.1 Initialization --------------
+
+  def initialize_core_96_head(
+    self,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_position: int = 5743,
+    z_deposit_position: int = 3425,
+    z_position_at_the_command_end: int = 3425
+  ):
+    """ Initialize CoRe 96 Head
+
+    Initialize CoRe 96 Head. Dependent to configuration initialization change.
+
+    Args:
+      x_position: X-Position [0.1mm] (discard position of tip A1). Must be between 0 and 30000.
+        Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: Y-Position [0.1mm] (discard position of tip A1 ). Must be between 1054 and 5743.
+        Default 5743.
+      z_deposit_position_[0.1mm]: Z- deposit position [0.1mm] (collar bearing position). Must be
+        between 0 and 3425. Default 3425.
+      z_position_at_the_command_end: Z-Position at the command end [0.1mm]. Must be between 0 and
+        3425. Default 3425.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_direction, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 1054, 5743, "y_position")
+    utils.assert_clamp(z_deposit_position, 0, 3425, "z_deposit_position")
+    utils.assert_clamp(z_position_at_the_command_end, 0, 3425, "z_position_at_the_command_end")
+
+    return self.send_command(
+      module="C0",
+      command="EI",
+      xs=x_position,
+      xd=x_direction,
+      yh=y_position,
+      za=z_deposit_position,
+      ze=z_position_at_the_command_end,
+    )
+
+  def move_core_96_to_safe_position(self):
+    """ Move CoRe 96 Head to Z save position """
+
+    resp = self.send_command(module="C0", command="EV")
+    return self.parse_response(resp, "")
+
+  # -------------- 3.10.2 Tip handling using CoRe 96 Head --------------
+
+  def pick_up_tip_core96(
+    self,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_position: int = 5600,
+    tip_type: FirmwareTipType = FirmwareTipType.STANDARD_VOLUME,
+    tip_pick_up_method: int = 2,
+    z_deposit_position: int = 3425,
+    minimum_traverse_height_at_beginning_of_a_command: int = 3425,
+    minimum_height_command_end: int = 3425
+  ):
+    """ Pick up tips with CoRe 96 head
+
+    Args:
+      x_position: x position [0.1mm]. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: y position [0.1mm]. Must be between 1080 and 5600. Default 5600.
+      tip_type: Tip type.
+      tip_pick_up_method: Tip pick up method. 0 = pick up from rack. 1 = pick up from C0Re 96 tip
+                          wash station. 2 = pick up with " full volume blow out"
+      z_deposit_position: Z- deposit position [0.1mm] (collar bearing position) Must bet between
+                          0 and 3425. Default 3425.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning
+          of a command [0.1mm]. Must be between 0 and 3425.
+      minimum_height_command_end: Minimal height at command end [0.1 mm] Must be between 0 and 3425.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_position, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 1080, 5600, "y_position")
+    utils.assert_clamp(z_deposit_position, 0, 3425, "z_deposit_position")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3425, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+    utils.assert_clamp(minimum_height_command_end, 0, 3425, "minimum_height_command_end")
+
+    return self.send_command(
+      module="C0",
+      command="EP",
+      xs=x_position,
+      xd=x_direction,
+      yh=y_position,
+      tt=tip_type, # .rawValue?
+      wu=tip_pick_up_method,
+      za=z_deposit_position,
+      zh=minimum_traverse_height_at_beginning_of_a_command,
+      ze=minimum_height_command_end
+    )
+
+  def discard_tip_core96(
+    self,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_position: int = 5600,
+    z_deposit_position: int = 3425,
+    minimum_traverse_height_at_beginning_of_a_command: int = 3425,
+    minimum_height_command_end: int = 3425
+  ):
+    """ Discard tips with CoRe 96 head
+
+    Args:
+      x_position: x position [0.1mm]. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: y position [0.1mm]. Must be between 1080 and 5600. Default 5600.
+      tip_type: Tip type.
+      tip_pick_up_method: Tip pick up method. 0 = pick up from rack. 1 = pick up from C0Re 96
+                          tip wash station. 2 = pick up with " full volume blow out"
+      z_deposit_position: Z- deposit position [0.1mm] (collar bearing position) Must bet between
+                          0 and 3425. Default 3425.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning
+          of a command [0.1mm]. Must be between 0 and 3425.
+      minimum_height_command_end: Minimal height at command end [0.1 mm] Must be between 0 and 3425
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_position, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 1080, 5600, "y_position")
+    utils.assert_clamp(z_deposit_position, 0, 3425, "z_deposit_position")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3425, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+    utils.assert_clamp(minimum_height_command_end, 0, 3425, "minimum_height_command_end")
+
+    return self.send_command(
+      module="C0",
+      command="ER",
+      xs=x_position,
+      xd=x_direction,
+      yh=y_position,
+      za=z_deposit_position,
+      zh=minimum_traverse_height_at_beginning_of_a_command,
+      ze=minimum_height_command_end
+    )
+
+  # -------------- 3.10.3 Liquid handling using CoRe 96 Head --------------
+
+  def aspirate_core_96(
+    self,
+    aspiration_type: int = 0,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_positions: int = 0,
+    minimum_traverse_height_at_beginning_of_a_command: int = 3425,
+    minimal_end_height: int = 3425,
+    lld_search_height: int = 3425,
+    liquid_surface_at_function_without_lld: int = 3425,
+    pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50,
+    maximum_immersion_depth: int = 3425,
+    tube_2nd_section_height_measured_from_zm: int = 0,
+    tube_2nd_section_ratio: int = 3425,
+    immersion_depth: int = 0,
+    immersion_depth_direction: int = 0,
+    liquid_surface_sink_distance_at_the_end_of_aspiration: int = 0,
+    aspiration_volumes: int = 0,
+    aspiration_speed: int = 1000,
+    transport_air_volume: int = 0,
+    blow_out_air_volume: int = 200,
+    pre_wetting_volume: int = 0,
+    lld_mode: int = 1,
+    gamma_lld_sensitivity: int = 1,
+    swap_speed: int = 100,
+    settling_time: int = 5,
+    homogenization_volume: int = 0,
+    homogenization_cycles: int = 0,
+    homogenization_position_from_liquid_surface: int = 250,
+    surface_following_distance_during_homogenization: int = 0,
+    speed_of_homogenization: int = 1000,
+    todo: int = None,
+    limit_curve_index: int = 0,
+    tadm_algorithm: bool = False,
+    recording_mode: int = 0
+  ):
+    """ aspirate CoRe 96
+
+    Aspiration of liquid using CoRe 96
+
+    Args:
+      aspiration_type: Type of aspiration (0 = simple; 1 = sequence; 2 = cup emptied). Must be
+          between 0 and 2. Default 0.
+      x_position: X-Position [0.1mm] of well A1. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_positions: Y-Position [0.1mm] of well A1. Must be between 1080 and 5600. Default 0.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of
+          a command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm').
+          Must be between 0 and 3425. Default 3425.
+      minimal_end_height: Minimal height at command end [0.1mm]. Must be between 0 and 3425.
+          Default 3425.
+      lld_search_height: LLD search height [0.1mm]. Must be between 0 and 3425. Default 3425.
+      liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm].
+          Must be between 0 and 3425. Default 3425.
+      pull_out_distance_to_take_transport_air_in_function_without_lld: pull out distance to take
+          transport air in function without LLD [0.1mm]. Must be between 0 and 3425. Default 50.
+      maximum_immersion_depth: Minimum height (maximum immersion depth) [0.1mm]. Must be between
+          0 and 3425. Default 3425.
+      tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from "zm" [0.1mm]
+           Must be between 0 and 3425. Default 0.
+      tube_2nd_section_ratio: Tube 2nd section ratio (See Fig 2.). Must be between 0 and 10000.
+          Default 3425.
+      immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0.
+      immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of
+          liquid). Must be between 0 and 1. Default 0.
+      liquid_surface_sink_distance_at_the_end_of_aspiration: Liquid surface sink distance at
+          the end of aspiration [0.1mm]. Must be between 0 and 990. Default 0.
+      aspiration_volumes: Aspiration volume [0.1ul]. Must be between 0 and 11500. Default 0.
+      aspiration_speed: Aspiration speed [0.1ul/s]. Must be between 3 and 5000. Default 1000.
+      transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0.
+      blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 11500. Default 200.
+      pre_wetting_volume: Pre-wetting volume. Must be between 0 and 11500. Default 0.
+      lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be between
+          0 and 4. Default 1.
+      gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4.
+          Default 1.
+      swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100.
+      settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5.
+      homogenization_volume: Homogenization volume [0.1ul]. Must be between 0 and 11500. Default 0.
+      homogenization_cycles: Number of homogenization cycles. Must be between 0 and 99. Default 0.
+      homogenization_position_from_liquid_surface: Homogenization position in Z- direction from
+          liquid surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250.
+      surface_following_distance_during_homogenization: surface following distance during
+          homogenization [0.1mm]. Must be between 0 and 990. Default 0.
+      speed_of_homogenization: Speed of homogenization [0.1ul/s]. Must be between 3 and 5000.
+          Default 1000.
+      todo: TODO: 24 hex chars. Must be between 4 and 5000.
+      limit_curve_index: limit curve index. Must be between 0 and 999. Default 0.
+      tadm_algorithm: TADM algorithm. Default False.
+      recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement.
+          Must be between 0 and 2. Default 0.
+    """
+
+    utils.assert_clamp(aspiration_type, 0, 2, "aspiration_type")
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_direction, 0, 1, "x_direction")
+    utils.assert_clamp(y_positions, 1080, 5600, "y_positions")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3425, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+    utils.assert_clamp(minimal_end_height, 0, 3425, "minimal_end_height")
+    utils.assert_clamp(lld_search_height, 0, 3425, "lld_search_height")
+    utils.assert_clamp(liquid_surface_at_function_without_lld, 0, 3425, \
+                  "liquid_surface_at_function_without_lld")
+    utils.assert_clamp(pull_out_distance_to_take_transport_air_in_function_without_lld, 0, 3425, \
+                  "pull_out_distance_to_take_transport_air_in_function_without_lld")
+    utils.assert_clamp(maximum_immersion_depth, 0, 3425, "maximum_immersion_depth")
+    utils.assert_clamp(tube_2nd_section_height_measured_from_zm, 0, 3425, \
+                  "tube_2nd_section_height_measured_from_zm")
+    utils.assert_clamp(tube_2nd_section_ratio, 0, 10000, "tube_2nd_section_ratio")
+    utils.assert_clamp(immersion_depth, 0, 3600, "immersion_depth")
+    utils.assert_clamp(immersion_depth_direction, 0, 1, "immersion_depth_direction")
+    utils.assert_clamp(liquid_surface_sink_distance_at_the_end_of_aspiration, 0, 990, \
+                  "liquid_surface_sink_distance_at_the_end_of_aspiration")
+    utils.assert_clamp(aspiration_volumes, 0, 11500, "aspiration_volumes")
+    utils.assert_clamp(aspiration_speed, 3, 5000, "aspiration_speed")
+    utils.assert_clamp(transport_air_volume, 0, 500, "transport_air_volume")
+    utils.assert_clamp(blow_out_air_volume, 0, 11500, "blow_out_air_volume")
+    utils.assert_clamp(pre_wetting_volume, 0, 11500, "pre_wetting_volume")
+    utils.assert_clamp(lld_mode, 0, 4, "lld_mode")
+    utils.assert_clamp(gamma_lld_sensitivity, 1, 4, "gamma_lld_sensitivity")
+    utils.assert_clamp(swap_speed, 3, 1000, "swap_speed")
+    utils.assert_clamp(settling_time, 0, 99, "settling_time")
+    utils.assert_clamp(homogenization_volume, 0, 11500, "homogenization_volume")
+    utils.assert_clamp(homogenization_cycles, 0, 99, "homogenization_cycles")
+    utils.assert_clamp(homogenization_position_from_liquid_surface, 0, 990, \
+                  "homogenization_position_from_liquid_surface")
+    utils.assert_clamp(surface_following_distance_during_homogenization, 0, 990, \
+                  "surface_following_distance_during_homogenization")
+    utils.assert_clamp(speed_of_homogenization, 3, 5000, "speed_of_homogenization")
+    utils.assert_clamp(todo, 4, 5000, "todo")
+    utils.assert_clamp(limit_curve_index, 0, 999, "limit_curve_index")
+
+    utils.assert_clamp(recording_mode, 0, 2, "recording_mode")
+
+    return self.send_command(
+      module="C0",
+      command="EA",
+      aa=aspiration_type,
+      xs=x_position,
+      xd=x_direction,
+      yh=y_positions,
+      zh=minimum_traverse_height_at_beginning_of_a_command,
+      ze=minimal_end_height,
+      lz=lld_search_height,
+      zt=liquid_surface_at_function_without_lld,
+      pp=pull_out_distance_to_take_transport_air_in_function_without_lld,
+      zm=maximum_immersion_depth,
+      zv=tube_2nd_section_height_measured_from_zm,
+      zq=tube_2nd_section_ratio,
+      iw=immersion_depth,
+      ix=immersion_depth_direction,
+      fh=liquid_surface_sink_distance_at_the_end_of_aspiration,
+      af=aspiration_volumes,
+      ag=aspiration_speed,
+      vt=transport_air_volume,
+      bv=blow_out_air_volume,
+      wv=pre_wetting_volume,
+      cm=lld_mode,
+      cs=gamma_lld_sensitivity,
+      bs=swap_speed,
+      wh=settling_time,
+      hv=homogenization_volume,
+      hc=homogenization_cycles,
+      hp=homogenization_position_from_liquid_surface,
+      mj=surface_following_distance_during_homogenization,
+      hs=speed_of_homogenization,
+      cw=todo,
+      cr=limit_curve_index,
+      cj=tadm_algorithm,
+      cx=recording_mode,
+    )
+
+  def dispense_core_96(
+    self,
+    dispensing_mode: int = 0,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_position: int = 0,
+    tube_2nd_section_height_measured_from_zm: int = 0,
+    tube_2nd_section_ratio: int = 3425,
+    lld_search_height: int = 3425,
+    liquid_surface_at_function_without_lld: int = 3425,
+    pull_out_distance_to_take_transport_air_in_function_without_lld: int = 50,
+    maximum_immersion_depth: int = 3425,
+    immersion_depth: int = 0,
+    immersion_depth_direction: int = 0,
+    liquid_surface_sink_elevation_at_the_end_of_aspiration: int = 0,
+    minimal_traverse_height_at_begin_of_command: int = 3425,
+    minimal_end_height: int = 3425,
+    dispense_volume: int = 0,
+    dispense_speed: int = 5000,
+    cut_off_speed: int = 250,
+    stop_back_volume: int = 0,
+    transport_air_volume: int = 0,
+    blow_out_air_volume: int = 200,
+    lld_mode: int = 1,
+    gamma_lld_sensitivity: int = 1,
+    swap_speed: int = 100,
+    settling_time: int = 5,
+    mixing_volume: int = 0,
+    mixing_cycles: int = 0,
+    mixing_position_from_liquid_surface: int = 250,
+    surface_following_distance_during_mixing: int = 0,
+    speed_of_mixing: int = 1000,
+    todo: int = None,
+    limit_curve_index: int = 0,
+    tadm_algorithm: bool = False,
+    recording_mode: int = 0
+  ):
+    """ dispense CoRe 96
+
+    Dispensing of liquid using CoRe 96
+
+    Args:
+      dispensing_mode: Type of dispsensing mode 0 = Partial volume in jet mode 1 = Blow out
+          in jet mode 2 = Partial volume at surface 3 = Blow out at surface 4 = Empty tip at fix
+          position. Must be between 0 and 4. Default 0.
+      x_position: X-Position [0.1mm] of well A1. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: Y-Position [0.1mm] of well A1. Must be between 1080 and 5600. Default 0.
+      maximum_immersion_depth: Minimum height (maximum immersion depth) [0.1mm]. Must be between
+          0 and 3425. Default 3425.
+      tube_2nd_section_height_measured_from_zm: Tube 2nd section height measured from
+          "zm" [0.1mm]. Must be between 0 and 3425. Default 0.
+      tube_2nd_section_ratio: Tube 2nd section ratio (See Fig 2.). Must be between 0 and 10000.
+          Default 3425.
+      lld_search_height: LLD search height [0.1mm]. Must be between 0 and 3425. Default 3425.
+      liquid_surface_at_function_without_lld: Liquid surface at function without LLD [0.1mm].
+          Must be between 0 and 3425. Default 3425.
+      pull_out_distance_to_take_transport_air_in_function_without_lld: pull out distance to take
+          transport air in function without LLD [0.1mm]. Must be between 0 and 3425. Default 50.
+      immersion_depth: Immersion depth [0.1mm]. Must be between 0 and 3600. Default 0.
+      immersion_depth_direction: Direction of immersion depth (0 = go deeper, 1 = go up out of
+          liquid). Must be between 0 and 1. Default 0.
+      liquid_surface_sink_elevation_at_the_end_of_aspiration: Liquid surface sink elevation at
+          the end of aspiration [0.1mm]. Must be between 0 and 990. Default 0.
+      minimal_traverse_height_at_begin_of_command: Minimal traverse height at begin of
+          command [0.1mm]. Must be between 0 and 3425. Default 3425.
+      minimal_end_height: Minimal height at command end [0.1mm]. Must be between 0 and 3425.
+          Default 3425.
+      dispense_volume: Dispense volume [0.1ul]. Must be between 0 and 11500. Default 0.
+      dispense_speed: Dispense speed [0.1ul/s]. Must be between 3 and 5000. Default 5000.
+      cut_off_speed: Cut-off speed [0.1ul/s]. Must be between 3 and 5000. Default 250.
+      stop_back_volume: Stop back volume [0.1ul/s]. Must be between 0 and 999. Default 0.
+      transport_air_volume: Transport air volume [0.1ul]. Must be between 0 and 500. Default 0.
+      blow_out_air_volume: Blow-out air volume [0.1ul]. Must be between 0 and 11500. Default 200.
+      lld_mode: LLD mode (0 = off, 1 = gamma, 2 = dP, 3 = dual, 4 = Z touch off). Must be
+          between 0 and 4. Default 1.
+      gamma_lld_sensitivity: gamma LLD sensitivity (1= high, 4=low). Must be between 1 and 4.
+          Default 1.
+      swap_speed: Swap speed (on leaving liquid) [0.1mm/s]. Must be between 3 and 1000. Default 100.
+      settling_time: Settling time [0.1s]. Must be between 0 and 99. Default 5.
+      mixing_volume: Homogenization volume [0.1ul]. Must be between 0 and 11500. Default 0.
+      mixing_cycles: Number of mixing cycles. Must be between 0 and 99. Default 0.
+      mixing_position_from_liquid_surface: Homogenization position in Z- direction from liquid
+          surface (LLD or absolute terms) [0.1mm]. Must be between 0 and 990. Default 250.
+      surface_following_distance_during_mixing: surface following distance during mixing [0.1mm].
+          Must be between 0 and 990. Default 0.
+      speed_of_mixing: Speed of mixing [0.1ul/s]. Must be between 3 and 5000. Default 1000.
+      todo: TODO: 24 hex chars. Must be between 4 and 5000.
+      limit_curve_index: limit curve index. Must be between 0 and 999. Default 0.
+      tadm_algorithm: TADM algorithm. Default False.
+      recording_mode: Recording mode 0 : no 1 : TADM errors only 2 : all TADM measurement. Must
+          be between 0 and 2. Default 0.
+    """
+
+    utils.assert_clamp(dispensing_mode, 0, 4, "dispensing_mode")
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_direction, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 1080, 5600, "y_position")
+    utils.assert_clamp(maximum_immersion_depth, 0, 3425, "maximum_immersion_depth")
+    utils.assert_clamp(tube_2nd_section_height_measured_from_zm, 0, 3425, \
+                  "tube_2nd_section_height_measured_from_zm")
+    utils.assert_clamp(tube_2nd_section_ratio, 0, 10000, "tube_2nd_section_ratio")
+    utils.assert_clamp(lld_search_height, 0, 3425, "lld_search_height")
+    utils.assert_clamp(liquid_surface_at_function_without_lld, 0, 3425, \
+                  "liquid_surface_at_function_without_lld")
+    utils.assert_clamp(pull_out_distance_to_take_transport_air_in_function_without_lld, 0, 3425, \
+                  "pull_out_distance_to_take_transport_air_in_function_without_lld")
+    utils.assert_clamp(immersion_depth, 0, 3600, "immersion_depth")
+    utils.assert_clamp(immersion_depth_direction, 0, 1, "immersion_depth_direction")
+    utils.assert_clamp(liquid_surface_sink_elevation_at_the_end_of_aspiration, 0, 990, \
+                  "liquid_surface_sink_elevation_at_the_end_of_aspiration")
+    utils.assert_clamp(minimal_traverse_height_at_begin_of_command, 0, 3425, \
+                  "minimal_traverse_height_at_begin_of_command")
+    utils.assert_clamp(minimal_end_height, 0, 3425, "minimal_end_height")
+    utils.assert_clamp(dispense_volume, 0, 11500, "dispense_volume")
+    utils.assert_clamp(dispense_speed, 3, 5000, "dispense_speed")
+    utils.assert_clamp(cut_off_speed, 3, 5000, "cut_off_speed")
+    utils.assert_clamp(stop_back_volume, 0, 999, "stop_back_volume")
+    utils.assert_clamp(transport_air_volume, 0, 500, "transport_air_volume")
+    utils.assert_clamp(blow_out_air_volume, 0, 11500, "blow_out_air_volume")
+    utils.assert_clamp(lld_mode, 0, 4, "lld_mode")
+    utils.assert_clamp(gamma_lld_sensitivity, 1, 4, "gamma_lld_sensitivity")
+    utils.assert_clamp(swap_speed, 3, 1000, "swap_speed")
+    utils.assert_clamp(settling_time, 0, 99, "settling_time")
+    utils.assert_clamp(mixing_volume, 0, 11500, "mixing_volume")
+    utils.assert_clamp(mixing_cycles, 0, 99, "mixing_cycles")
+    utils.assert_clamp(mixing_position_from_liquid_surface, 0, 990, \
+                  "mixing_position_from_liquid_surface")
+    utils.assert_clamp(surface_following_distance_during_mixing, 0, 990, \
+                  "surface_following_distance_during_mixing")
+    utils.assert_clamp(speed_of_mixing, 3, 5000, "speed_of_mixing")
+    utils.assert_clamp(todo, 4, 5000, "todo")
+    utils.assert_clamp(limit_curve_index, 0, 999, "limit_curve_index")
+    utils.assert_clamp(recording_mode, 0, 2, "recording_mode")
+
+    return self.send_command(
+      module="C0",
+      command="ED",
+      dm=dispensing_mode,
+      xs=x_position,
+      xd=x_direction,
+      yh=y_position,
+      zm=maximum_immersion_depth,
+      zv=tube_2nd_section_height_measured_from_zm,
+      zq=tube_2nd_section_ratio,
+      lz=lld_search_height,
+      zt=liquid_surface_at_function_without_lld,
+      pp=pull_out_distance_to_take_transport_air_in_function_without_lld,
+      iw=immersion_depth,
+      ix=immersion_depth_direction,
+      fh=liquid_surface_sink_elevation_at_the_end_of_aspiration,
+      zh=minimal_traverse_height_at_begin_of_command,
+      ze=minimal_end_height,
+      df=dispense_volume,
+      dg=dispense_speed,
+      es=cut_off_speed,
+      ev=stop_back_volume,
+      vt=transport_air_volume,
+      bv=blow_out_air_volume,
+      cm=lld_mode,
+      cs=gamma_lld_sensitivity,
+      bs=swap_speed,
+      wh=settling_time,
+      hv=mixing_volume,
+      hc=mixing_cycles,
+      hp=mixing_position_from_liquid_surface,
+      mj=surface_following_distance_during_mixing,
+      hs=speed_of_mixing,
+      cw=todo,
+      cr=limit_curve_index,
+      cj=tadm_algorithm,
+      cx=recording_mode,
+    )
+
+  # -------------- 3.10.4 Adjustment & movement commands --------------
+
+  def move_core_96_head_to_defined_position(
+    self,
+    dispsensing_mode: int = 0,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_position: int = 0,
+    z_position: int = 0,
+    minimum_height_at_beginning_of_a_command: int = 3425
+  ):
+    """ Move CoRe 96 Head to defined position
+
+    Args:
+      dispsensing_mode: Type of dispsensing mode 0 = Partial volume in jet mode 1 = Blow out
+                        in jet mode 2 = Partial volume at surface 3 = Blow out at surface 4 = Empty
+                        tip at fix position. Must be between 0 and 4. Default 0.
+      x_position: X-Position [0.1mm] of well A1. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: Y-Position [0.1mm]. Must be between 1080 and 5600. Default 0.
+      z_position: Z-Position [0.1mm]. Must be between 0 and 5600. Default 0.
+      minimum_height_at_beginning_of_a_command: Minimum height at beginning of a command 0.1mm]
+                        (refers to all channels independent of tip pattern parameter 'tm'). Must be
+                        between 0 and 3425. Default 3425.
+    """
+
+    utils.assert_clamp(dispsensing_mode, 0, 4, "dispsensing_mode")
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_direction, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 1080, 5600, "y_position")
+    utils.assert_clamp(y_position, 0, 5600, "z_position")
+    utils.assert_clamp(minimum_height_at_beginning_of_a_command, 0, 3425, \
+                  "minimum_height_at_beginning_of_a_command")
+
+    return self.send_command(
+      module="C0",
+      command="EM",
+      dm=dispsensing_mode,
+      xs=x_position,
+      xd=x_direction,
+      yh=y_position,
+      za=z_position,
+      zh=minimum_height_at_beginning_of_a_command,
+    )
+
+  # -------------- 3.10.5 Wash procedure commands using CoRe 96 Head --------------
+
+  # TODO:(command:EG) Washing tips using CoRe 96 Head
+  # TODO:(command:EU) Empty washed tips (end of wash procedure only)
+
+  # -------------- 3.10.6 Query CoRe 96 Head --------------
+
+  def request_tip_presence_in_core_96_head(self):
+    """ Request Tip presence in CoRe 96 Head
+
+    Returns:
+      qh: 0 = no tips, 1 = Tips are picked up
+    """
+
+    resp = self.send_command(module="C0", command="QH")
+    return self.parse_response(resp, "qh#")
+
+  def request_position_of_core_96_head(self):
+    """ Request position of CoRe 96 Head (A1 considered to tip length)
+
+    Returns:
+      xs: A1 X direction [0.1mm]
+      xd: X direction 0 = positive 1 = negative
+      yh: A1 Y direction [0.1mm]
+      za: Z height [0.1mm]
+    """
+
+    resp = self.send_command(module="C0", command="QI")
+    return self.parse_response(resp, "xs#####xd#hy####za####")
+
+  def request_core_96_head_channel_tadm_status(self):
+    """ Request CoRe 96 Head channel TADM Status
+
+    Returns:
+      qx: TADM channel status 0 = off 1 = on
+    """
+
+    resp = self.send_command(module="C0", command="VC")
+    return self.parse_response(resp, "qx#")
+
+  def request_core_96_head_channel_tadm_error_status(self):
+    """ Request CoRe 96 Head channel TADM error status
+
+    Returns:
+      vb: error pattern 0 = no error
+    """
+
+    resp = self.send_command(module="C0", command="VB")
+    return self.parse_response(resp, "vb" + "&" * 24)
+
+  # -------------- 3.11 384 Head commands --------------
+
+  # -------------- 3.11.1 Initialization --------------
+
+  # -------------- 3.11.2 Tip handling using 384 Head --------------
+
+  # -------------- 3.11.3 Liquid handling using 384 Head --------------
+
+  # -------------- 3.11.4 Adjustment & movement commands --------------
+
+  # -------------- 3.11.5 Wash procedure commands using 384 Head --------------
+
+  # -------------- 3.11.6 Query 384 Head --------------
+
+  # -------------- 3.12 Nano pipettor commands --------------
+
+  # TODO: all nano pipettor commands
+
+  # -------------- 3.12.1 Initialization --------------
+
+  # TODO:(commandL:NI)
+  # TODO:(commandL:NV)
+  # TODO:(commandL:NP)
+
+  # -------------- 3.12.2 Nano pipettor liquid handling commands --------------
+
+  # TODO:(commandL:NA)
+  # TODO:(commandL:ND)
+  # TODO:(commandL:NF)
+
+  # -------------- 3.12.3 Nano pipettor wash & clean commands --------------
+
+  # TODO:(commandL:NW)
+  # TODO:(commandL:NU)
+
+  # -------------- 3.12.4 Nano pipettor adjustment & movements --------------
+
+  # TODO:(commandL:NM)
+  # TODO:(commandL:NT)
+
+  # -------------- 3.12.5 Nano pipettor query --------------
+
+  # TODO:(commandL:QL)
+  # TODO:(commandL:QN)
+  # TODO:(commandL:RN)
+  # TODO:(commandL:QQ)
+  # TODO:(commandL:QR)
+  # TODO:(commandL:QO)
+  # TODO:(commandL:RR)
+  # TODO:(commandL:QU)
+
+  # -------------- 3.13 Auto load commands --------------
+
+  # -------------- 3.13.1 Initialization --------------
+
+  def initialize_auto_load(self):
+    """ Initialize Auto load module """
+
+    resp = self.send_command(module="C0", command="II")
+    return self.parse_response(resp, "")
+
+  def move_auto_load_to_z_save_position(self):
+    """ Move auto load to Z save position """
+
+    resp = self.send_command(module="C0", command="IV")
+    return self.parse_response(resp, "")
+
+  # -------------- 3.13.2 Carrier handling --------------
+
+  # TODO:(command:CI) Identify carrier (determine carrier type)
+
+  def request_single_carrier_presence(
+    self,
+    carrier_position: int
+  ):
+    """ Request single carrier presence
+
+    Args:
+      carrier_position: Carrier position (slot number)
+
+    Returns:
+      True if present, False otherwise
+    """
+
+    utils.assert_clamp(carrier_position, 1, 54, "carrier_position")
+
+    resp = self.send_command(
+      module="C0",
+      command="CT",
+      cp=carrier_position
+    )
+    return self.parse_response(resp, "ct#")["ct"] == 1
+
+  # TODO:(command:CA) Push out carrier to loading tray (after identification CI)
+
+  # TODO:(command:CR) Unload carrier
+
+  # TODO:(command:CL) Load carrier
+
+  def set_loading_indicators(
+    self,
+    bit_pattern: typing.List[bool],
+    blink_pattern: typing.List[bool]
+  ):
+    """ Set loading indicators (LEDs)
+
+    The docs here are a little weird because 2^54 < 7FFFFFFFFFFFFF.
+
+    Args:
+      bit_pattern: On if True, off otherwise
+      blink_pattern: Blinking if True, steady otherwise
+    """
+
+    assert len(bit_pattern) == 54, "bit pattern must be length 54"
+    assert len(blink_pattern) == 54, "bit pattern must be length 54"
+
+    bit_pattern   = hex(int("".join(["1" if x else "0" for x in bit_pattern]), base=2))
+    blink_pattern = hex(int("".join(["1" if x else "0" for x in blink_pattern]), base=2))
+
+    resp = self.send_command(
+      module="C0",
+      command="CP",
+      cl=bit_pattern,
+      cb=blink_pattern
+    )
+    return self.parse_response(resp, "")
+
+  # TODO:(command:CS) Check for presence of carriers on loading tray
+
+  def set_barcode_type(
+    self,
+    ISBT_Standard: bool = True,
+    code128: bool = True,
+    code39: bool = True,
+    codebar: bool = True,
+    code2_5: bool = True,
+    UPC_AE: bool = True,
+    EAN8: bool = True
+  ):
+    """ Set bar code type: which types of barcodes will be scanned for.
+
+    Args:
+      ISBT_Standard: ISBT_Standard. Default True.
+      code128: Code128. Default True.
+      code39: Code39. Default True.
+      codebar: Codebar. Default True.
+      code2_5: Code2_5. Default True.
+      UPC_AE: UPC_AE. Default True.
+      EAN8: EAN8. Default True.
+    """
+
+    # pylint: disable=invalid-name
+
+    # Encode values into bit pattern. Last bit is always one.
+    bt = ""
+    for t in [ISBT_Standard, code128, code39, codebar, code2_5, UPC_AE, EAN8, True]:
+      bt += "1" if t else "0"
+
+    # Convert bit pattern to hex.
+    bt = hex(int(bt), base=2)
+
+    resp = self.send_command(
+      module="C0",
+      command="CB",
+      bt=bt
+    )
+    return self.parse_response(resp, "")
+
+  # TODO:(command:CW) Unload carrier finally
+
+  def set_carrier_monitoring(
+    self,
+    should_monitor: bool = False
+  ):
+    """ Set carrier monitoring
+
+    Args:
+      should_monitor: whether carrier should be monitored.
+
+    Returns:
+      True if present, False otherwise
+    """
+
+    resp = self.send_command(
+      module="C0",
+      command="CU",
+      cu=should_monitor
+    )
+    return self.parse_response(resp, "")
+
+  # TODO:(command:CN) Take out the carrier to identification position
+
+  # -------------- 3.13.3 Auto load query --------------
+
+  # TODO:(command:RC) Query presence of carrier on deck
+
+  def request_auto_load_slot_position(self):
+    """ Request auto load slot position.
+
+    Returns:
+      slot position (0..54)
+    """
+
+    resp = self.send_command(module="C0", command="QA")
+    return self.parse_response(resp, "qa##")
+
+  # TODO:(command:CQ) Request auto load module type
+
+  # -------------- 3.14 G1-3/ CR Needle Washer commands --------------
+
+  # TODO: All needle washer commands
+
+  # TODO:(command:WI)
+  # TODO:(command:WI)
+  # TODO:(command:WS)
+  # TODO:(command:WW)
+  # TODO:(command:WR)
+  # TODO:(command:WC)
+  # TODO:(command:QF)
+
+  # -------------- 3.15 Pump unit commands --------------
+
+  def request_pump_settings(
+    self,
+    pump_station: int = 1
+  ):
+    """ Set carrier monitoring
+
+    Args:
+      carrier_position: pump station number (1..3)
+
+    Returns:
+      0 = CoRe 96 wash station (single chamber)
+      1 = DC wash station (single chamber rev 02 ) 2 = ReReRe (single chamber)
+      3 = CoRe 96 wash station (dual chamber)
+      4 = DC wash station (dual chamber)
+      5 = ReReRe (dual chamber)
+    """
+
+    utils.assert_clamp(pump_station, 1, 3, "pump_station")
+
+    resp = self.send_command(
+      module="C0",
+      command="ET",
+      ep=pump_station
+    )
+    return self.parse_response(resp, "et#")
+
+  # -------------- 3.15.1 DC Wash commands (only for revision up to 01) --------------
+
+  # TODO:(command:FA) Start DC wash procedure
+  # TODO:(command:FB) Stop DC wash procedure
+  # TODO:(command:FP) Prime DC wash station
+
+  # -------------- 3.15.2 Single chamber pump unit only --------------
+
+  # TODO:(command:EW) Start circulation (single chamber only)
+  # TODO:(command:EC) Check circulation (single chamber only)
+  # TODO:(command:ES) Stop circulation (single chamber only)
+  # TODO:(command:EF) Prime (single chamber only)
+  # TODO:(command:EE) Drain & refill (single chamber only)
+  # TODO:(command:EB) Fill (single chamber only)
+  # TODO:(command:QE) Request single chamber pump station prime status
+
+  # -------------- 3.15.3 Dual chamber pump unit only --------------
+
+  def initialize_dual_pump_station_valves(
+    self,
+    pump_station: int = 1
+  ):
+    """ Initialize pump station valves (dual chamber only)
+
+    Args:
+      carrier_position: pump station number (1..3)
+    """
+
+    utils.assert_clamp(pump_station, 1, 3, "pump_station")
+
+    resp = self.send_command(
+      module="C0",
+      command="EJ",
+      ep=pump_station
+    )
+    return self.parse_response(resp, "")
+
+  def fill_selected_dual_chamber(
+    self,
+    pump_station: int = 1,
+    drain_before_refill: bool = False,
+    wash_fluid: int = 1,
+    chamber: int = 2,
+    waste_chamber_suck_time_after_sensor_change: int = 0
+  ):
+    """ Initialize pump station valves (dual chamber only)
+
+    Args:
+      carrier_position: pump station number (1..3)
+      drain_before_refill: drain chamber before refill. Default False.
+      wash_fluid: wash fluid (1 or 2)
+      chamber: chamber (1 or 2)
+      drain_before_refill: waste chamber suck time after sensor change [s] (for error handling only)
+    """
+
+    utils.assert_clamp(pump_station, 1, 3, "pump_station")
+    utils.assert_clamp(wash_fluid, 1, 2, "wash_fluid")
+    utils.assert_clamp(chamber, 1, 2, "chamber")
+
+    # wash fluid <-> chamber connection
+    # 0 = wash fluid 1 <-> chamber 2
+    # 1 = wash fluid 1 <-> chamber 1
+    # 2 = wash fluid 2 <-> chamber 1
+    # 3 = wash fluid 2 <-> chamber 2
+    connection = None
+    if wash_fluid == 1:
+      connection = 0 if chamber == 2 else 1
+    if wash_fluid == 2:
+      connection = 2 if chamber == 1 else 3
+
+    resp = self.send_command(
+      module="C0",
+      command="EJ",
+      ep=pump_station,
+      ed=drain_before_refill,
+      ek=connection,
+      eu=waste_chamber_suck_time_after_sensor_change
+    )
+    return self.parse_response(resp, "")
+
+  # TODO:(command:EK) Drain selected chamber
+
+  def drain_dual_chamber_system(
+    self,
+    pump_station: int = 1
+  ):
+    """ Drain system (dual chamber only)
+
+    Args:
+      carrier_position: pump station number (1..3)
+    """
+
+    utils.assert_clamp(pump_station, 1, 3, "pump_station")
+
+    resp = self.send_command(
+      module="C0",
+      command="EL",
+      ep=pump_station
+    )
+    return self.parse_response(resp, "")
+
+  # TODO:(command:QD) Request dual chamber pump station prime status
+
+  # -------------- 3.16 Incubator commands --------------
+
+  # TODO: all incubator commands
+  # TODO:(command:HC)
+  # TODO:(command:HI)
+  # TODO:(command:HF)
+  # TODO:(command:RP)
+
+  # -------------- 3.17 iSWAP commands --------------
+
+  # -------------- 3.17.1 Pre & Initialization commands --------------
+
+  def initialize_iswap(self):
+    """ Initialize iSWAP (for standalone configuration only) """
+
+    resp = self.send_command(module="C0", command="FI")
+    return self.parse_response(resp, "")
+
+  def position_components_for_free_iswap_y_range(self):
+    """ Position all components so that there is maximum free Y range for iSWAP """
+
+    resp = self.send_command(module="C0", command="FY")
+    return self.parse_response(resp, "")
+
+  def move_iswap_x_direction(
+    self,
+    step_size: int = 0,
+    direction: int = 0
+  ):
+    """ Move iSWAP in X-direction
+
+    Args:
+      step_size: X Step size [0.1mm] Between 0 and 999. Default 0.
+      direction: X direction. 0 = positive 1 = negative
+    """
+
+    resp = self.send_command(
+      module="C0",
+      command="GX",
+      gx=step_size,
+      xd=direction
+    )
+    return self.parse_response(resp, "")
+
+  def move_iswap_y_direction(
+    self,
+    step_size: int = 0,
+    direction: int = 0
+  ):
+    """ Move iSWAP in Y-direction
+
+    Args:
+      step_size: Y Step size [0.1mm] Between 0 and 999. Default 0.
+      direction: Y direction. 0 = positive 1 = negative
+    """
+
+    resp = self.send_command(
+      module="C0",
+      command="GY",
+      gx=step_size,
+      xd=direction
+    )
+    return self.parse_response(resp, "")
+
+  def move_iswap_z_direction(
+    self,
+    step_size: int = 0,
+    direction: int = 0
+  ):
+    """ Move iSWAP in Z-direction
+
+    Args:
+      step_size: Z Step size [0.1mm] Between 0 and 999. Default 0.
+      direction: Z direction. 0 = positive 1 = negative
+    """
+
+    resp = self.send_command(
+      module="C0",
+      command="GZ",
+      gx=step_size,
+      xd=direction
+    )
+    return self.parse_response(resp, "")
+
+  def open_not_initialized_gripper(self):
+    """ Open not initialized gripper """
+
+    resp = self.send_command(module="C0", command="GI")
+    return self.parse_response(resp, "")
+
+  def open_gripper(
+    self,
+    open_position: int = 860
+  ):
+    """ Open gripper
+
+    Args:
+      open_position: Open position [0.1mm] (0.1 mm = 16 increments) The gripper moves to pos + 20.
+                     Must be between 0 and 9999. Default 860.
+    """
+
+    utils.assert_clamp(open_position, 0, 9999, "open_position")
+
+    resp = self.send_command(
+      module="C0",
+      command="GC",
+      go=open_position
+    )
+    return self.parse_response(resp, "")
+
+  def close_gripper(
+    self,
+    grip_strength: int = 5,
+    plate_width: int = 0,
+    plate_width_tolerance: int = 0
+  ):
+    """ Close gripper
+
+    The gripper should be at the position gb+gt+20 before sending this command.
+
+    Args:
+      grip_strength: Grip strength. 0 = low . 9 = high. Default 5.
+      plate_width: Plate width [0.1mm]
+                   (gb should be > min. Pos. + stop ramp + gt -> gb > 760 + 5 + g )
+      plate_width_tolerance: Plate width tolerance [0.1mm]. Must be between 0 and 99. Default 20.
+    """
+
+    resp = self.send_command(
+      module="C0",
+      command="GC",
+      gw=grip_strength,
+      gb=plate_width,
+      gt=plate_width_tolerance
+    )
+    return self.parse_response(resp, "")
+
+  # -------------- 3.17.2 Stack handling commands CP --------------
+
+  def park_iswap(
+    self,
+    minimum_traverse_height_at_beginning_of_a_command: int = 3600
+  ):
+    """ Close gripper
+
+    The gripper should be at the position gb+gt+20 before sending this command.
+
+    Args:
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning
+                of a command [0.1mm]. Must be between 0 and 3600. Default 3600.
+    """
+
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+
+    resp = self.send_command(
+      module="C0",
+      command="GC",
+      th=minimum_traverse_height_at_beginning_of_a_command
+    )
+    return self.parse_response(resp, "")
+
+  def get_plate(
+    self,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_position: int = 0,
+    y_direction: int = 0,
+    z_position: int = 0,
+    z_direction: int = 0,
+    grip_direction: int = 1,
+    minimum_traverse_height_at_beginning_of_a_command: int = 3600,
+    z_position_at_the_command_end: int = 3600,
+    grip_strength: int = 5,
+    open_gripper_position: int = 860,
+    plate_width: int = 860,
+    plate_width_tolerance: int = 860,
+    collision_control_level: int = 1,
+    acceleration_index_high_acc: int = 4,
+    acceleration_index_low_acc: int = 1,
+    fold_up_sequence_at_the_end_of_process: bool = True
+  ):
+    """ get plate
+
+    Args:
+      x_position: Plate center in X direction  [0.1mm]. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0.
+      y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0.
+      z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y,
+            4 =negative X. Must be between 1 and 4. Default 1.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of
+            a command 0.1mm]. Must be between 0 and 3600. Default 3600.
+      z_position_at_the_command_end: Z-Position at the command end [0.1mm]. Must be between 0
+            and 3600. Default 3600.
+      grip_strength: Grip strength 0 = low .. 9 = high. Must be between 1 and 9. Default 5.
+      open_gripper_position: Open gripper position [0.1mm]. Must be between 0 and 9999.
+            Default 860.
+      plate_width: plate width [0.1mm]. Must be between 0 and 9999. Default 860.
+      plate_width_tolerance: plate width tolerance [0.1mm]. Must be between 0 and 99. Default 860.
+      collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1.
+                               Default 1.
+      acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4.
+      acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. Default 1.
+      fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default True.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_direction, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 0, 6500, "y_position")
+    utils.assert_clamp(y_direction, 0, 1, "y_direction")
+    utils.assert_clamp(z_position, 0, 3600, "z_position")
+    utils.assert_clamp(z_direction, 0, 1, "z_direction")
+    utils.assert_clamp(grip_direction, 1, 4, "grip_direction")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+    utils.assert_clamp(z_position_at_the_command_end, 0, 3600, "z_position_at_the_command_end")
+    utils.assert_clamp(grip_strength, 1, 9, "grip_strength")
+    utils.assert_clamp(open_gripper_position, 0, 9999, "open_gripper_position")
+    utils.assert_clamp(plate_width, 0, 9999, "plate_width")
+    utils.assert_clamp(plate_width_tolerance, 0, 99, "plate_width_tolerance")
+    utils.assert_clamp(collision_control_level, 0, 1, "collision_control_level")
+    utils.assert_clamp(acceleration_index_high_acc, 0, 4, "acceleration_index_high_acc")
+    utils.assert_clamp(acceleration_index_low_acc, 0, 4, "acceleration_index_low_acc")
+
+    return self.send_command(
+      module="C0",
+      command="PP",
+      xs=x_position,
+      xd=x_direction,
+      yj=y_position,
+      yd=y_direction,
+      zj=z_position,
+      zd=z_direction,
+      gr=grip_direction,
+      th=minimum_traverse_height_at_beginning_of_a_command,
+      te=z_position_at_the_command_end,
+      gw=grip_strength,
+      go=open_gripper_position,
+      gb=plate_width,
+      gt=plate_width_tolerance,
+      ga=collision_control_level,
+      xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}",
+      gc=fold_up_sequence_at_the_end_of_process,
+    )
+
+  def put_plate(
+    self,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_position: int = 0,
+    y_direction: int = 0,
+    z_position: int = 0,
+    z_direction: int = 0,
+    grip_direction: int = 1,
+    minimum_traverse_height_at_beginning_of_a_command: int = 3600,
+    z_position_at_the_command_end: int = 3600,
+    open_gripper_position: int = 860,
+    collision_control_level: int = 1,
+    acceleration_index_high_acc: int = 4,
+    acceleration_index_low_acc: int = 1
+  ):
+    """ put plate
+
+    Args:
+      x_position: Plate center in X direction  [0.1mm]. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0.
+      y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0.
+      z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, 4 = negative
+            X. Must be between 1 and 4. Default 1.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a
+            command 0.1mm]. Must be between 0 and 3600. Default 3600.
+      z_position_at_the_command_end: Z-Position at the command end [0.1mm]. Must be between 0 and
+            3600. Default 3600.
+      open_gripper_position: Open gripper position [0.1mm]. Must be between 0 and 9999. Default
+            860.
+      collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1.
+            Default 1.
+      acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4.
+            Default 4.
+      acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4.
+            Default 1.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_direction, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 0, 6500, "y_position")
+    utils.assert_clamp(y_direction, 0, 1, "y_direction")
+    utils.assert_clamp(z_position, 0, 3600, "z_position")
+    utils.assert_clamp(z_direction, 0, 1, "z_direction")
+    utils.assert_clamp(grip_direction, 1, 4, "grip_direction")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+    utils.assert_clamp(z_position_at_the_command_end, 0, 3600, "z_position_at_the_command_end")
+    utils.assert_clamp(open_gripper_position, 0, 9999, "open_gripper_position")
+    utils.assert_clamp(collision_control_level, 0, 1, "collision_control_level")
+    utils.assert_clamp(acceleration_index_high_acc, 0, 4, "acceleration_index_high_acc")
+    utils.assert_clamp(acceleration_index_low_acc, 0, 4, "acceleration_index_low_acc")
+
+    return self.send_command(
+      module="C0",
+      command="PR",
+      xs=x_position,
+      xd=x_direction,
+      yj=y_position,
+      yd=y_direction,
+      zj=z_position,
+      zd=z_direction,
+      gr=grip_direction,
+      th=minimum_traverse_height_at_beginning_of_a_command,
+      te=z_position_at_the_command_end,
+      go=open_gripper_position,
+      ga=collision_control_level,
+      xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}"
+    )
+
+  def move_plate_to_position(
+    self,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_position: int = 0,
+    y_direction: int = 0,
+    z_position: int = 0,
+    z_direction: int = 0,
+    grip_direction: int = 1,
+    minimum_traverse_height_at_beginning_of_a_command: int = 3600,
+    collision_control_level: int = 1,
+    acceleration_index_high_acc: int = 4,
+    acceleration_index_low_acc: int = 1
+  ):
+    """ Move plate to position.
+
+    Args:
+      x_position: Plate center in X direction  [0.1mm]. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0.
+      y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0.
+      z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, 4 = negative
+            X. Must be between 1 and 4. Default 1.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a
+            command 0.1mm]. Must be between 0 and 3600. Default 3600.
+      collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1.
+            Default 1.
+      acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4.
+      acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. Default 1.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_direction, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 0, 6500, "y_position")
+    utils.assert_clamp(y_direction, 0, 1, "y_direction")
+    utils.assert_clamp(z_position, 0, 3600, "z_position")
+    utils.assert_clamp(z_direction, 0, 1, "z_direction")
+    utils.assert_clamp(grip_direction, 1, 4, "grip_direction")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+    utils.assert_clamp(collision_control_level, 0, 1, "collision_control_level")
+    utils.assert_clamp(acceleration_index_high_acc, 0, 4, "acceleration_index_high_acc")
+    utils.assert_clamp(acceleration_index_low_acc, 0, 4, "acceleration_index_low_acc")
+
+    return self.send_command(
+      module="C0",
+      command="PM",
+      xs=x_position,
+      xd=x_direction,
+      yj=y_position,
+      yd=y_direction,
+      zj=z_position,
+      zd=z_direction,
+      gr=grip_direction,
+      th=minimum_traverse_height_at_beginning_of_a_command,
+      ga=collision_control_level,
+      xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}"
+    )
+
+  def collapse_gripper_arm(
+    self,
+    minimum_traverse_height_at_beginning_of_a_command: int = 3600,
+    fold_up_sequence_at_the_end_of_process: bool = True
+  ):
+    """ Collapse gripper arm
+
+    Args:
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a
+                                                         command 0.1mm]. Must be between 0 and 3600.
+                                                         Default 3600.
+      fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default True.
+    """
+
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+
+    return self.send_command(
+      module="C0",
+      command="PN",
+      th=minimum_traverse_height_at_beginning_of_a_command,
+      gc=fold_up_sequence_at_the_end_of_process,
+    )
+
+  # -------------- 3.17.3 Hotel handling commands --------------
+
+  # TODO:(command:PO) Get plate from hotel
+  # TODO:(command:PI) Put plate to hotel
+
+  # -------------- 3.17.4 Barcode commands --------------
+
+  # TODO:(command:PB) Read barcode using iSWAP
+
+  # -------------- 3.17.5 Teach in commands --------------
+
+  def prepare_iswap_teaching(
+  self,
+  x_position: int = 0,
+  x_direction: int = 0,
+  y_position: int = 0,
+  y_direction: int = 0,
+  z_position: int = 0,
+  z_direction: int = 0,
+  location: int = 0,
+  hotel_depth: int = 0,
+  minimum_traverse_height_at_beginning_of_a_command: int = 3600,
+  collision_control_level: int = 1,
+  acceleration_index_high_acc: int = 4,
+  acceleration_index_low_acc: int = 1
+):
+    """ Prepare iSWAP teaching
+
+    Prepare for teaching with iSWAP
+
+    Args:
+      x_position: Plate center in X direction  [0.1mm]. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0.
+      y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0.
+      z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0.
+      hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 13000.
+      minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of
+                                                         a command 0.1mm]. Must be between 0 and
+                                                         3600. Default 3600.
+      collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1.
+                               Default 1.
+      acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4.
+                                   Default 4.
+      acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4.
+                                  Default 1.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_direction, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 0, 6500, "y_position")
+    utils.assert_clamp(y_direction, 0, 1, "y_direction")
+    utils.assert_clamp(z_position, 0, 3600, "z_position")
+    utils.assert_clamp(z_direction, 0, 1, "z_direction")
+    utils.assert_clamp(location, 0, 1, "location")
+    utils.assert_clamp(hotel_depth, 0, 3000, "hotel_depth")
+    utils.assert_clamp(minimum_traverse_height_at_beginning_of_a_command, 0, 3600, \
+                  "minimum_traverse_height_at_beginning_of_a_command")
+    utils.assert_clamp(collision_control_level, 0, 1, "collision_control_level")
+    utils.assert_clamp(acceleration_index_high_acc, 0, 4, "acceleration_index_high_acc")
+    utils.assert_clamp(acceleration_index_low_acc, 0, 4, "acceleration_index_low_acc")
+
+    return self.send_command(
+      module="C0",
+      command="PT",
+      xs=x_position,
+      xd=x_direction,
+      yj=y_position,
+      yd=y_direction,
+      zj=z_position,
+      zd=z_direction,
+      hh=location,
+      hd=hotel_depth,
+      th=minimum_traverse_height_at_beginning_of_a_command,
+      ga=collision_control_level,
+      xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}"
+    )
+
+  def get_logic_iswap_position(
+    self,
+    x_position: int = 0,
+    x_direction: int = 0,
+    y_position: int = 0,
+    y_direction: int = 0,
+    z_position: int = 0,
+    z_direction: int = 0,
+    location: int = 0,
+    hotel_depth: int = 1300,
+    grip_direction: int = 1,
+    collision_control_level: int = 1
+  ):
+    """ Get logic iSWAP position
+
+    Args:
+      x_position: Plate center in X direction  [0.1mm]. Must be between 0 and 30000. Default 0.
+      x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0.
+      y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0.
+      z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0.
+      location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0.
+      hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300.
+      grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y,
+                      4 = negative X. Must be between 1 and 4. Default 1.
+      collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1.
+                               Default 1.
+    """
+
+    utils.assert_clamp(x_position, 0, 30000, "x_position")
+    utils.assert_clamp(x_direction, 0, 1, "x_direction")
+    utils.assert_clamp(y_position, 0, 6500, "y_position")
+    utils.assert_clamp(y_direction, 0, 1, "y_direction")
+    utils.assert_clamp(z_position, 0, 3600, "z_position")
+    utils.assert_clamp(z_direction, 0, 1, "z_direction")
+    utils.assert_clamp(location, 0, 1, "location")
+    utils.assert_clamp(hotel_depth, 0, 3000, "hotel_depth")
+    utils.assert_clamp(grip_direction, 1, 4, "grip_direction")
+    utils.assert_clamp(collision_control_level, 0, 1, "collision_control_level")
+
+    return self.send_command(
+      module="C0",
+      command="PC",
+      xs=x_position,
+      xd=x_direction,
+      yj=y_position,
+      yd=y_direction,
+      zj=z_position,
+      zd=z_direction,
+      hh=location,
+      hd=hotel_depth,
+      gr=grip_direction,
+      ga=collision_control_level,
+    )
+
+  # -------------- 3.17.6 iSWAP query --------------
+
+  def request_iswap_in_parking_position(self):
+    """ Request iSWAP in parking position
+
+    Returns:
+      0 = gripper is not in parking position
+      1 = gripper is in parking position
+    """
+
+    resp = self.send_command(module="C0", command="RG")
+    return self.parse_response(resp, "rg#")
+
+  def request_plate_in_iswap(self):
+    """ Request plate in iSWAP
+
+    Returns:
+      0 = plate not holding
+      1 = plate holding
+    """
+
+    resp = self.send_command(module="C0", command="QP")
+    return self.parse_response(resp, "rg#")
+
+  def request_iswap_position(self):
+    """ Request iSWAP position ( grip center )
+
+    Returns:
+      xs: Hotel center in X direction [0.1mm]
+      xd: X direction 0 = positive 1 = negative
+      yj: Gripper center in Y direction [0.1mm]
+      yd: Y direction 0 = positive 1 = negative
+      zj: Gripper Z height (gripping height) [0.1mm]
+      zd: Z direction 0 = positive 1 = negative
+    """
+
+    resp = self.send_command(module="C0", command="QG")
+    return self.parse_response(resp, "xs#####xd#yj####yd#zj####zd#")
diff --git a/pyhamilton/liquid_handling/backends/hamilton/VENUS/README.md b/pyhamilton/liquid_handling/backends/hamilton/VENUS/README.md
new file mode 100644
index 0000000..a0de544
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/hamilton/VENUS/README.md
@@ -0,0 +1,3 @@
+# Venus backend
+
+This directory contains the VENUS backend as well as all functionality of the first version of PyHamilton.
diff --git a/pyhamilton/liquid_handling/backends/hamilton/VENUS/VENUS.py b/pyhamilton/liquid_handling/backends/hamilton/VENUS/VENUS.py
new file mode 100644
index 0000000..f50814c
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/hamilton/VENUS/VENUS.py
@@ -0,0 +1,260 @@
+""" VENUS backend runs through the VENUS program (Windows only) """
+
+import re
+import typing
+
+from pyhamilton.liquid_handling.backends.backend import LiquidHandlerBackend
+from pyhamilton.liquid_handling.resources.abstract import Resource
+from pyhamilton.liquid_handling.liquid_handler import AspirationInfo, DispenseInfo
+
+from .deckresource import (
+  DeckResource,
+  LayoutManager,
+  ResourceType,
+  Plate24,
+  Plate96,
+  Plate384,
+  Tip96
+)
+from .interface import *
+
+from pyhamilton.utils.positions import string_to_position
+
+import pyhamilton.utils.file_parsing as file_parser
+
+from . import venus_utils
+
+
+class VENUS(LiquidHandlerBackend):
+  """ Backend for the VENUS control software.
+
+  This class servers as a wrapper for the "old" pyhamilton library, which is still available for
+  backwards compatibility, to make it compatible with the new `LiquidHandlerBackend` and
+  `LiquidHandler` classes.
+  """
+
+  def __init__(self, layout_file: str):
+    """ Initializes the backend.
+    
+    When `setup()` is called, the backend will load the resources from the given layout file and
+    assign them automatically using the name they have in the lay file.
+    """
+
+    super().__init__()
+    self.layout_file = layout_file
+    self.lmgr = LayoutManager(layout_file)
+
+    self._venus_resources = {}
+
+  def setup(self):
+    super().setup()
+
+    self._load_resources_from_layfile()
+
+    self.ham_int = HamiltonInterface()
+    self.ham_int.start()
+    self.ham_int.wait_on_response(self.ham_int.send_command(INITIALIZE))
+
+  def _load_resources_from_layfile(self) -> typing.List[Resource]:
+    """ Loads the resources from the given layfile.  """
+
+    with open(self.layout_file, "r", encoding="ISO-8859-1") as f:
+      c = f.read()
+
+      # Loop through all the resources in the layfile, and create a DeckResource for the ones that
+      # can automatically be parsed.
+      num_items = file_parser.find_int("Labware.Cnt", c)
+      for i in range(1, num_items+1):
+        resource_template = file_parser.find_string(f"Labware.{i}.File", c)
+        name = file_parser.find_string(f"Labware.{i}.Id", c)
+
+        # use the name as a proxy to the type of labware
+        if re.search(r"Cos_[\S]+\.rck", resource_template):
+          if "24" in resource_template:
+            resource_type = Plate24
+          elif "96" in resource_template:
+            resource_type = Plate96
+          elif "384" in resource_template:
+            resource_type = Plate384
+          else:
+            raise ValueError(f"Unknown plate type: {name}")
+
+          resource = ResourceType(resource_type, name)
+        elif re.search(r"([LHS]|([45]ml))TF?(_[LP])?\.rck", resource_template):
+          resource = ResourceType(Tip96, name)
+        else:
+          print("Unknown resource type:", resource_template, "for", name)
+          continue
+
+        self._venus_resources[name] = self.lmgr.assign_unused_resource(resource)
+        print("Assigned resource:", name)
+  
+  def assign_unused_resource(self, name: str, resource_type: ResourceType):
+    """ Assign a resource using the layout manager.
+    
+    While many resources are automatically assigned, some are not. This method allows you to assign
+    resources that were not automatically assigned.
+    """
+
+    self._venus_resources[name] = self.lmgr.assign_unused_resource(resource_type)
+
+  def stop(self):
+    self.ham_int.stop()
+    super().stop()
+
+  def assigned_resource_callback(self, resource: Resource):
+    raise RuntimeError("VENUS backend does not support assigning resources.")
+
+  def unassigned_resource_callback(self, name: str):
+    raise RuntimeError("VENUS backend does not support assigning resources.")
+
+  def _get_venus_resource(self, resource: typing.Union[str, Resource]) -> DeckResource:
+    name = resource if isinstance(resource, str) else resource.name
+    return self._venus_resources[resource]
+
+  def pickup_tips(
+    self,
+    resource: typing.Union[Resource, str],
+    channel_1: typing.Optional[str] = None,
+    channel_2: typing.Optional[str] = None,
+    channel_3: typing.Optional[str] = None,
+    channel_4: typing.Optional[str] = None,
+    channel_5: typing.Optional[str] = None,
+    channel_6: typing.Optional[str] = None,
+    channel_7: typing.Optional[str] = None,
+    channel_8: typing.Optional[str] = None,
+    **backend_kwargs
+  ):
+    """ Pick up tips from the specified resource. """
+
+    venus_resource = self._get_venus_resource(resource)
+    pos_tuples = []
+    last_column = None
+
+    for channel in [channel_1, channel_2, channel_3, channel_4,
+                    channel_5, channel_6, channel_7, channel_8]:
+      if channel is not None:
+        column = string_to_position(channel_1)[1]
+        if last_column and column < last_column:
+          raise ValueError("With this backend, tips must be picked up in ascending column order.")
+        pos_tuples.append((venus_resource, column))
+      else:
+        pos_tuples.append(None)
+
+    venus_utils.tip_pick_up(self.ham_int, pos_tuples, **backend_kwargs)
+
+  def discard_tips(
+    self,
+    resource: typing.Union[Resource, str],
+    channel_1: typing.Optional[str] = None,
+    channel_2: typing.Optional[str] = None,
+    channel_3: typing.Optional[str] = None,
+    channel_4: typing.Optional[str] = None,
+    channel_5: typing.Optional[str] = None,
+    channel_6: typing.Optional[str] = None,
+    channel_7: typing.Optional[str] = None,
+    channel_8: typing.Optional[str] = None,
+    **backend_kwargs
+  ):
+    """ Discard tips from the specified resource. """
+    venus_resource = self._get_venus_resource(resource)
+    pos_tuples = []
+    last_column = None
+
+    for channel in [channel_1, channel_2, channel_3, channel_4,
+                    channel_5, channel_6, channel_7, channel_8]:
+      if channel is not None:
+        column = string_to_position(channel_1)[1]
+        if last_column and column < last_column:
+          raise ValueError("With this backend, tips must be picked up in ascending column order.")
+        pos_tuples.append((venus_resource, column))
+      else:
+        pos_tuples.append(None)
+
+    venus_utils.tip_eject(self.ham_int, pos_tuples, **backend_kwargs)
+
+  def aspirate(
+    self,
+    resource: typing.Union[Resource, str],
+    channel_1: typing.Optional[AspirationInfo] = None,
+    channel_2: typing.Optional[AspirationInfo] = None,
+    channel_3: typing.Optional[AspirationInfo] = None,
+    channel_4: typing.Optional[AspirationInfo] = None,
+    channel_5: typing.Optional[AspirationInfo] = None,
+    channel_6: typing.Optional[AspirationInfo] = None,
+    channel_7: typing.Optional[AspirationInfo] = None,
+    channel_8: typing.Optional[AspirationInfo] = None,
+    **backend_kwargs
+  ):
+    """ Aspirate liquid from the specified resource using pip. """
+    venus_resource = self._get_venus_resource(resource)
+    pos_tuples = []
+    volumes = []
+    last_column = None
+
+    for channel in [channel_1, channel_2, channel_3, channel_4,
+                    channel_5, channel_6, channel_7, channel_8]:
+      if channel is not None:
+        column = string_to_position(channel_1.position)[1]
+        if last_column and column < last_column:
+          raise ValueError("With this backend, tips must be picked up in ascending column order.")
+        pos_tuples.append((venus_resource, column))
+        volumes.append(channel.volume)
+      else:
+        pos_tuples.append(None)
+        volumes.append(None)
+
+    venus_utils.aspirate(self.ham_int, pos_tuples, volumes, **backend_kwargs)
+
+  def dispense(
+    self,
+    resource: typing.Union[Resource, str],
+    channel_1: typing.Optional[DispenseInfo] = None,
+    channel_2: typing.Optional[DispenseInfo] = None,
+    channel_3: typing.Optional[DispenseInfo] = None,
+    channel_4: typing.Optional[DispenseInfo] = None,
+    channel_5: typing.Optional[DispenseInfo] = None,
+    channel_6: typing.Optional[DispenseInfo] = None,
+    channel_7: typing.Optional[DispenseInfo] = None,
+    channel_8: typing.Optional[DispenseInfo] = None,
+    **backend_kwargs
+  ):
+    """ Dispense liquid from the specified resource using pip. """
+    venus_resource = self._get_venus_resource(resource)
+    pos_tuples = []
+    volumes = []
+    last_column = None
+
+    for channel in [channel_1, channel_2, channel_3, channel_4,
+                    channel_5, channel_6, channel_7, channel_8]:
+      if channel is not None:
+        column = string_to_position(channel_1.position)[1]
+        if last_column and column < last_column:
+          raise ValueError("With this backend, tips must be picked up in ascending column order.")
+        pos_tuples.append((venus_resource, column))
+        volumes.append(channel.volume)
+      else:
+        pos_tuples.append(None)
+        volumes.append(None)
+
+    venus_utils.dispense(self.ham_int, pos_tuples, volumes, **backend_kwargs)
+
+  def pickup_tips96(self, resource, **backend_kwargs):
+    """ Pick up tips from the specified resource using CoRe 96. """
+    venus_resource = self._get_venus_resource(resource)
+    venus_utils.tip_pick_up_96(self.ham_int, venus_resource, **backend_kwargs)
+
+  def discard_tips96(self, resource, **backend_kwargs):
+    """ Discard tips to the specified resource using CoRe 96. """
+    venus_resource = self._get_venus_resource(resource)
+    venus_utils.tip_eject_96(self.ham_int, venus_resource, **backend_kwargs)
+
+  def aspirate96(self, resource, pattern, volume, **backend_kwargs):
+    """ Aspirate liquid from the specified resource using CoRe 96. """
+    venus_resource = self._get_venus_resource(resource)
+    venus_utils.aspirate_96(self.ham_int, venus_resource, volume, **backend_kwargs)
+
+  def dispense96(self, resource, pattern, volume, **backend_kwargs):
+    """ Dispense liquid to the specified resource using CoRe 96. """
+    venus_resource = self._get_venus_resource(resource)
+    venus_utils.dispense_96(self.ham_int, venus_resource, volume, **backend_kwargs)
diff --git a/pyhamilton/liquid_handling/backends/hamilton/VENUS/__init__.py b/pyhamilton/liquid_handling/backends/hamilton/VENUS/__init__.py
new file mode 100644
index 0000000..8d8634e
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/hamilton/VENUS/__init__.py
@@ -0,0 +1,1 @@
+from .VENUS import VENUS
diff --git a/pyhamilton/deckresource.py b/pyhamilton/liquid_handling/backends/hamilton/VENUS/deckresource.py
similarity index 99%
rename from pyhamilton/deckresource.py
rename to pyhamilton/liquid_handling/backends/hamilton/VENUS/deckresource.py
index 0911307..bb49db7 100644
--- a/pyhamilton/deckresource.py
+++ b/pyhamilton/liquid_handling/backends/hamilton/VENUS/deckresource.py
@@ -5,10 +5,10 @@
 """
 import string, shutil, os, string, re
 from datetime import datetime
-from pyhamilton import OEM_LAY_PATH, LAY_BACKUP_DIR
+# from pyhamilton.liquid_handling.backends.hamilton.VENUS import OEM_LAY_PATH, LAYOUT_DIR
+from .paths import OEM_LAY_PATH, LAY_BACKUP_DIR
 from .oemerr import ResourceUnavailableError
 
-
 class ResourceType:
     """
     Specifies a type of labware to extract using LayoutManager, and how.
diff --git a/pyhamilton/defaultcmds.py b/pyhamilton/liquid_handling/backends/hamilton/VENUS/defaultcmds.py
similarity index 100%
rename from pyhamilton/defaultcmds.py
rename to pyhamilton/liquid_handling/backends/hamilton/VENUS/defaultcmds.py
diff --git a/pyhamilton/interface.py b/pyhamilton/liquid_handling/backends/hamilton/VENUS/interface.py
similarity index 97%
rename from pyhamilton/interface.py
rename to pyhamilton/liquid_handling/backends/hamilton/VENUS/interface.py
index 2a936ee..936df81 100644
--- a/pyhamilton/interface.py
+++ b/pyhamilton/liquid_handling/backends/hamilton/VENUS/interface.py
@@ -1,11 +1,28 @@
-import time, json, signal, os, requests, string, logging, subprocess, win32gui, win32con
+import time, json, signal, os, requests, string, logging, subprocess
 from http import server
 from threading import Thread
 from multiprocessing import Process
-from pyhamilton import OEM_RUN_EXE_PATH, OEM_HSL_PATH
+
+from pyhamilton.liquid_handling.backends.hamilton.errors import error_code_to_exception, UnknownHamiltonError
+
+# from pyhamilton.liquid_handling.backends.hamilton.VENUS import OEM_RUN_EXE_PATH, OEM_HSL_PATH
+from .paths import OEM_RUN_EXE_PATH, OEM_HSL_PATH
 from .oemerr import * #TODO: specify
+# TODO: replace HAMILTON_ERROR_MAP with new error handling system
 from .defaultcmds import defaults_by_cmd
 
+logger = logging.getLogger(__name__)
+logging.basicConfig()
+logger.setLevel(logging.INFO)
+
+try:
+    import win32gui, win32con
+    USE_WINDOWS = True
+except ImportError:
+    logger.warn("Could not import win32gui or win32con, VENUS interface will not be available.")
+    USE_WINDOWS = False
+
+
 class HamiltonCmdTemplate:
     """
     Formatter object to create valid `pyhamilton` command dicts.
@@ -503,9 +520,9 @@ def pop_response(self, id, raise_first_exception=False, return_data=False):
                 errcode = blocks[blocknum][_block_mainerrfield]
                 if errcode != 0:
                     self.log('Exception encoded in Hamilton return.', 'warn')
-                    try:
-                        decoded_exception = HAMILTON_ERROR_MAP[errcode]()
-                    except KeyError:
+                    # decoded_exception = HAMILTON_ERROR_MAP[errcode]()
+                    decoded_exception = error_code_to_exception(errcode)
+                    if isinstance(err, UnknownHamiltonError):
                         self.log_and_raise(InvalidErrCodeError('Response returned had an unknown error code: ' + str(errcode)))
                     self.log('Exception: ' + repr(decoded_exception), 'warn')
                     if raise_first_exception:
diff --git a/pyhamilton/liquid_handling/backends/hamilton/VENUS/oemerr.py b/pyhamilton/liquid_handling/backends/hamilton/VENUS/oemerr.py
new file mode 100644
index 0000000..b2fa4c2
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/hamilton/VENUS/oemerr.py
@@ -0,0 +1,49 @@
+"""`pyhamilton`-specific exception definitions.  """
+
+from pyhamilton.liquid_handling.backends.hamilton.errors import VENUSError
+# for legacy, import all errors here
+from pyhamilton.liquid_handling.backends.hamilton.errors import *
+
+###########################################
+### BEGIN HAMILTON DECK RESOURCE ERRORS ###
+###########################################
+
+class HamiltonDeckResourceError(VENUSError):
+    """
+    Error with any deck object in interface with robot.
+    """
+    pass
+
+class ResourceUnavailableError(HamiltonDeckResourceError):
+    """
+    Layout manager found deck resource type not present or all of this type assigned
+    """
+    pass
+
+#######################################
+### BEGIN HAMILTON INTERFACE ERRORS ###
+#######################################
+
+class HamiltonInterfaceError(VENUSError):
+    """
+    Error in any phase of communication with robot.
+    """
+    pass
+
+class HamiltonTimeoutError(HamiltonInterfaceError):
+    """
+    An asynchronous request to the Hamilton robot timed out.
+    """
+    pass
+
+class InvalidErrCodeError(HamiltonInterfaceError):
+    """
+    Error code returned from instrument not known.
+    """
+    pass
+
+class HamiltonReturnParseError(HamiltonInterfaceError):
+    """
+    Return string from instrument was malformed.
+    """
+    pass
diff --git a/pyhamilton/liquid_handling/backends/hamilton/VENUS/paths.py b/pyhamilton/liquid_handling/backends/hamilton/VENUS/paths.py
new file mode 100644
index 0000000..db19532
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/hamilton/VENUS/paths.py
@@ -0,0 +1,14 @@
+import os
+from pyhamilton import PACKAGE_PATH
+
+LAY_BACKUP_DIR = os.path.join(PACKAGE_PATH, 'LAY-BACKUP')
+if not os.path.exists(LAY_BACKUP_DIR):
+    os.mkdir(LAY_BACKUP_DIR)
+OEM_STAR_PATH = os.path.join(PACKAGE_PATH, 'star-oem')
+if not (os.path.exists(OEM_STAR_PATH)
+		and os.path.exists(os.path.join(OEM_STAR_PATH, 'RunHSLExecutor.dll'))
+		and os.path.exists(os.path.join(OEM_STAR_PATH, 'HSLHttp.dll'))):
+    raise FileNotFoundError('pyhamilton requires .../site-packages/pyhamilton/STAR-OEM, distributed separately.')
+OEM_LAY_PATH = os.path.join(OEM_STAR_PATH, 'VENUS_Method', 'STAR_OEM_Test.lay')
+OEM_HSL_PATH = os.path.join(OEM_STAR_PATH, 'VENUS_Method', 'STAR_OEM_noFan.hsl')
+OEM_RUN_EXE_PATH = 'C:\\Program Files (x86)\\HAMILTON\\Bin\\HxRun.exe'
diff --git a/pyhamilton/util.py b/pyhamilton/liquid_handling/backends/hamilton/VENUS/util.py
similarity index 100%
rename from pyhamilton/util.py
rename to pyhamilton/liquid_handling/backends/hamilton/VENUS/util.py
diff --git a/pyhamilton/utils.py b/pyhamilton/liquid_handling/backends/hamilton/VENUS/venus_utils.py
similarity index 98%
rename from pyhamilton/utils.py
rename to pyhamilton/liquid_handling/backends/hamilton/VENUS/venus_utils.py
index fce20a1..2cd6802 100644
--- a/pyhamilton/utils.py
+++ b/pyhamilton/liquid_handling/backends/hamilton/VENUS/venus_utils.py
@@ -1,3 +1,5 @@
+# pylint: skip-file
+
 # -*- coding: utf-8 -*-
 """
 Created on Wed Mar  9 12:01:01 2022
@@ -10,7 +12,7 @@
 
 from .interface import HamiltonInterface
 from .deckresource import LayoutManager, ResourceType, Plate24, Plate96, Tip96
-from .oemerr import PositionError
+from .oemerr import PositionNotReachableError
 from .interface import (INITIALIZE, PICKUP, EJECT, ASPIRATE, DISPENSE, ISWAP_GET, ISWAP_PLACE, HEPA,
 WASH96_EMPTY, PICKUP96, EJECT96, ASPIRATE96, DISPENSE96, ISWAP_MOVE, MOVE_SEQ)
 
@@ -52,7 +54,7 @@ def hepa_on(ham, speed=15, asynch=False, **more_options):
     cmd = ham.send_command(HEPA, fanSpeed=speed, **more_options)
     if not asynch:
         ham.wait_on_response(cmd, raise_first_exception=True)
-    return 
+    return
 
 def wash_empty_refill(ham, asynch=False, **more_options):
     logging.info('wash_empty_refill: empty the washer' +
@@ -73,14 +75,14 @@ def move_plate(ham, source_plate, target_plate, gripHeight = 6, try_inversions=N
         try:
             ham.wait_on_response(cid, raise_first_exception=True, timeout=120)
             break
-        except PositionError:
+        except PositionNotReachableError:
             pass
     else:
         raise IOError
     cid = ham.send_command(ISWAP_PLACE, plateLabwarePositions=trgt_pos)
     try:
         ham.wait_on_response(cid, raise_first_exception=True, timeout=120)
-    except PositionError:
+    except PositionNotReachableError:
         raise IOError
 
 def move_by_seq(ham, source_plate_seq, target_plate_seq, grip_height = 0, try_inversions=None, gripForce = 2):
@@ -94,14 +96,14 @@ def move_by_seq(ham, source_plate_seq, target_plate_seq, grip_height = 0, try_in
         try:
             ham.wait_on_response(cid, raise_first_exception=True, timeout=120)
             break
-        except PositionError:
+        except PositionNotReachableError:
             pass
     else:
         raise IOError
     cid = ham.send_command(ISWAP_PLACE, plateSequence=target_plate_seq)
     try:
         ham.wait_on_response(cid, raise_first_exception=True, timeout=120)
-    except PositionError:
+    except PositionNotReachableError:
         raise IOError
 
 def channel_var(pos_tuples):
@@ -194,7 +196,7 @@ def tip_eject_96(ham_int, tip96=None, **more_options):
     if tip96 is None:
         labware_poss = ''
         more_options.update({'tipEjectToKnownPosition':2}) # 2 is default waste
-    else:   
+    else:
         labware_poss = compound_pos_str_96(tip96)
     ham_int.wait_on_response(ham_int.send_command(EJECT96,
         labwarePositions=labware_poss,
@@ -238,7 +240,7 @@ def write(self, message):
 def add_stderr_logging(logger_name=None):
     logger = logging.getLogger(logger_name) # root logger if None
     sys.stderr = StderrLogger(logger.error)
-    
+
 def normal_logging(ham_int):
     local_log_dir = os.path.join(method_local_dir, 'log')
     if not os.path.exists(local_log_dir):
diff --git a/pyhamilton/liquid_handling/backends/hamilton/__init__.py b/pyhamilton/liquid_handling/backends/hamilton/__init__.py
new file mode 100644
index 0000000..09b0573
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/hamilton/__init__.py
@@ -0,0 +1,2 @@
+from .STAR import STAR
+from .VENUS import VENUS
diff --git a/pyhamilton/liquid_handling/backends/hamilton/errors.py b/pyhamilton/liquid_handling/backends/hamilton/errors.py
new file mode 100644
index 0000000..d851522
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/hamilton/errors.py
@@ -0,0 +1,839 @@
+""" Hamilton backend errors """
+
+from abc import ABCMeta
+import typing
+
+
+class HamiltonModuleError(Exception, metaclass=ABCMeta):
+  """ Base class for all Hamilton backend errors, raised by a single module. """
+
+  def __init__(
+    self,
+    message: str,
+    raw_response: typing.Optional[str] = None,
+    raw_module: typing.Optional[str] = None
+  ):
+    super().__init__(message)
+    self.message = message
+    self.raw_response = raw_response
+    self.raw_module = raw_module
+
+
+class CommandSyntaxError(HamiltonModuleError):
+  """ Command syntax error
+
+  Code: 01
+  """
+
+  pass
+
+
+class HardwareError(HamiltonModuleError):
+  """ Hardware error
+
+  Possible cause(s):
+    drive blocked, low power etc.
+
+  Code: 02
+  """
+
+  pass
+
+
+class CommandNotCompletedError(HamiltonModuleError):
+  """ Command not completed
+
+  Possible cause(s):
+    error in previous sequence (not executed)
+
+  Code: 03
+  """
+
+  pass
+
+
+class ClotDetectedError(HamiltonModuleError):
+  """ Clot detected
+
+  Possible cause(s):
+    LLD not interrupted
+
+  Code: 04
+  """
+
+  pass
+
+
+class BarcodeUnreadableError(HamiltonModuleError):
+  """ Barcode unreadable
+
+  Possible cause(s):
+    bad or missing barcode
+
+  Code: 05
+  """
+
+  pass
+
+
+class TooLittleLiquidError(HamiltonModuleError):
+  """ Too little liquid
+
+  Possible cause(s):
+    1. liquid surface is not detected,
+    2. Aspirate / Dispense conditions could not be fulfilled.
+
+  Code: 06
+  """
+
+  pass
+
+
+class TipAlreadyFittedError(HamiltonModuleError):
+  """ Tip already fitted
+
+  Possible cause(s):
+    Repeated attempts to fit a tip or iSwap movement with tips
+
+  Code: 07
+  """
+
+  pass
+
+
+class NoTipError(HamiltonModuleError):
+  """ No tips
+
+  Possible cause(s):
+    command was started without fitting tip (tip was not fitted or fell off again)
+
+  Code: 08
+  """
+
+  pass
+
+
+class NoCarrierError(HamiltonModuleError):
+  """ No carrier
+
+  Possible cause(s):
+    load command without carrier
+
+  Code: 09
+  """
+
+  pass
+
+
+class NotCompletedError(HamiltonModuleError):
+  """ Not completed
+
+  Possible cause(s):
+    Command in command buffer was aborted due to an error in a previous command, or command stack
+    was deleted.
+
+  Code: 10
+  """
+
+  pass
+
+
+class DispenseWithPressureLLDError(HamiltonModuleError):
+  """ Dispense with  pressure LLD
+
+  Possible cause(s):
+    dispense with pressure LLD is not permitted
+
+  Code: 11
+  """
+
+  pass
+
+
+class NoTeachInSignalError(HamiltonModuleError):
+  """ No Teach  In Signal
+
+  Possible cause(s):
+    X-Movement to LLD reached maximum allowable position with- out detecting Teach in signal
+
+  Code: 12
+  """
+
+  pass
+
+
+class LoadingTrayError(HamiltonModuleError):
+  """ Loading  Tray error
+
+  Possible cause(s):
+    position already occupied
+
+  Code: 13
+  """
+
+  pass
+
+
+class SequencedAspirationWithPressureLLDError(HamiltonModuleError):
+  """ Sequenced aspiration with  pressure LLD
+
+  Possible cause(s):
+    sequenced aspiration with pressure LLD is not permitted
+
+  Code: 14
+  """
+
+  pass
+
+
+class NotAllowedParameterCombinationError(HamiltonModuleError):
+  """ Not allowed  parameter combination
+
+  Possible cause(s):
+    i.e. PLLD and dispense or wrong X-drive assignment
+
+  Code: 15
+  """
+
+  pass
+
+
+class CoverCloseError(HamiltonModuleError):
+  """Cover close error
+
+  Possible cause(s):
+    cover is not closed and couldn't be locked
+
+  Code: 16
+  """
+
+  pass
+
+
+class AspirationError(HamiltonModuleError):
+  """ Aspiration error
+
+  Possible cause(s):
+    aspiration liquid stream error detected
+
+
+  Code: 17
+  """
+
+  pass
+
+
+class WashFluidOrWasteError(HamiltonModuleError):
+  """Wash fluid or waste error
+
+  Possible cause(s):
+    1. missing wash fluid
+    2. waste of particular washer is full
+
+  Code: 18
+  """
+
+  pass
+
+
+class IncubationError(HamiltonModuleError):
+  """ Incubation error
+
+  Possible cause(s):
+    incubator temperature out of limit
+
+  Code: 19
+  """
+
+  pass
+
+
+class TADMMeasurementError(HamiltonModuleError):
+  """TADM measurement error
+
+  Possible cause(s):
+    overshoot of limits during aspiration or dispensation
+
+  Code: 20, 26
+  """
+
+  pass
+
+
+class NoElementError(HamiltonModuleError):
+  """ No element
+
+  Possible cause(s):
+    expected element not detected
+
+  Code: 21
+  """
+
+  pass
+
+
+class ElementStillHoldingError(HamiltonModuleError):
+  """Element still holding
+
+  Possible cause(s):
+    "Get command" is sent twice or element is not discarded expected element is missing (lost)
+
+  Code: 22
+  """
+
+  pass
+
+
+class ElementLostError(HamiltonModuleError):
+  """ Element lost
+
+  Possible cause(s):
+    expected element is missing (lost)
+
+  Code: 23
+  """
+
+  pass
+
+
+class IllegalTargetPlatePositionError(HamiltonModuleError):
+  """Illegal target plate position
+
+  Possible cause(s):
+    1. over or underflow of iSWAP positions
+    2. iSWAP is not in park position during pipetting activities
+
+  Code: 24
+  """
+
+  pass
+
+
+class IllegalUserAccessError(HamiltonModuleError):
+  """Illegal user access
+
+  Possible cause(s):
+    carrier was manually removed or cover is open (immediate stop is executed)
+
+  Code: 25
+  """
+
+  pass
+
+
+class PositionNotReachableError(HamiltonModuleError):
+  """Position not reachable
+
+  Possible cause(s):
+    position out of mechanical limits using iSWAP, CoRe gripper or PIP-channels
+
+  Code: 27
+  """
+
+  pass
+
+
+class UnexpectedLLDError(HamiltonModuleError):
+  """ unexpected LLD
+
+  Possible cause(s):
+    liquid level is reached before LLD scanning is started (using PIP or XL channels)
+
+  Code: 28
+  """
+
+  pass
+
+
+class AreaAlreadyOccupiedError(HamiltonModuleError):
+  """ area already occupied
+
+  Possible cause(s):
+    Its impossible to occupy area because this area is already in use
+
+  Code: 29
+  """
+
+  pass
+
+
+class ImpossibleToOccupyAreaError(HamiltonModuleError):
+  """ impossible to occupy area
+
+  Possible cause(s):
+    Area cant be occupied because is no solution for arm prepositioning
+
+  Code: 30
+  """
+
+  pass
+
+
+class AntiDropControlError(HamiltonModuleError):
+  """
+  Anti drop controlling out of tolerance. (VENUS only)
+
+  Code: 31
+  """
+
+  pass
+
+
+class DecapperError(HamiltonModuleError):
+  """
+  Decapper lock error while screw / unscrew a cap by twister channels. (VENUS only)
+
+  Code: 32
+  """
+
+  pass
+
+
+class DecapperHandlingError(HamiltonModuleError):
+  """
+  Decapper station error while lock / unlock a cap. (VENUS only)
+
+  Code: 33
+  """
+
+  pass
+
+
+class SlaveError(HamiltonModuleError):
+  """ Slave error
+
+  Possible cause(s):
+    This error code indicates an error in one of slaves. (for error handling purpose using service
+    software macro code)
+
+  Code: 99
+  """
+
+  pass
+
+
+class WrongCarrierError(HamiltonModuleError):
+  """
+  Wrong carrier barcode detected. (VENUS only)
+
+  Code: 100
+  """
+
+  pass
+
+
+class NoCarrierBarcodeError(HamiltonModuleError):
+  """
+  Carrier barcode could not be read or is missing. (VENUS only)
+
+  Code: 101
+  """
+
+  pass
+
+
+class LiquidLevelError(HamiltonModuleError):
+  """
+  Liquid surface not detected. (VENUS only)
+
+  This error is created from main / slave error 06/70, 06/73 and 06/87.
+
+  Code: 102
+  """
+
+  pass
+
+
+class NotDetectedError(HamiltonModuleError):
+  """
+  Carrier not detected at deck end position. (VENUS only)
+
+  Code: 103
+  """
+
+  pass
+
+
+class NotAspiratedError(HamiltonModuleError):
+  """
+  Dispense volume exceeds the aspirated volume. (VENUS only)
+
+  This error is created from main / slave error 02/54.
+
+  Code: 104
+  """
+
+  pass
+
+
+class ImproperDispensationError(HamiltonModuleError):
+  """
+  The dispensed volume is out of tolerance (may only occur for Nano Pipettor Dispense steps).
+  (VENUS only)
+
+  This error is created from main / slave error 02/52 and 02/54.
+
+  Code: 105
+  """
+
+  pass
+
+
+class NoLabwareError(HamiltonModuleError):
+  """
+  The labware to be loaded was not detected by autoload module. (VENUS only)
+
+  Note:
+
+  May only occur on a Reload Carrier step if the labware property 'MlStarCarPosAreRecognizable' is
+  set to 1.
+
+  Code: 106
+  """
+
+  pass
+
+
+class UnexpectedLabwareError(HamiltonModuleError):
+  """
+  The labware contains unexpected barcode ( may only occur on a Reload Carrier step ). (VENUS only)
+
+  Code: 107
+  """
+
+  pass
+
+
+class WrongLabwareError(HamiltonModuleError):
+  """
+  The labware to be reloaded contains wrong barcode ( may only occur on a Reload Carrier step ).
+  (VENUS only)
+
+  Code: 108
+  """
+
+  pass
+
+
+class BarcodeMaskError(HamiltonModuleError):
+  """
+  The barcode read doesn't match the barcode mask defined. (VENUS only)
+
+  Code: 109
+  """
+
+  pass
+
+
+class BarcodeNotUniqueError(HamiltonModuleError):
+  """
+  The barcode read is not unique. Previously loaded labware with same barcode was loaded without
+  unique barcode check. (VENUS only)
+
+  Code: 110
+  """
+
+  pass
+
+
+class BarcodeAlreadyUsedError(HamiltonModuleError):
+  """
+  The barcode read is already loaded as unique barcode ( it's not possible to load the same barcode
+  twice ). (VENUS only)
+
+  Code: 111
+  """
+
+  pass
+
+
+class KitLotExpiredError(HamiltonModuleError):
+  """
+  Kit Lot expired. (VENUS only)
+
+  Code: 112
+  """
+
+  pass
+
+
+class DelimiterError(HamiltonModuleError):
+  """
+  Barcode contains character which is used as delimiter in result string. (VENUS only)
+
+  Code: 113
+  """
+
+  pass
+
+
+class UnknownHamiltonError(HamiltonModuleError):
+  """ Unknown error """
+
+  pass
+
+
+def _module_id_to_module_name(id_):
+  """ Convert a module ID to a module name. """
+  return {
+    "C0": "Master",
+    "X0": "X-drives",
+    "I0": "Auto Load",
+    "W1": "Wash station 1-3",
+    "W2": "Wash station 4-6",
+    "T1": "Temperature carrier 1",
+    "T2": "Temperature carrier 2",
+    "R0": "ISWAP",
+    "P1": "Pipetting channel 1",
+    "P2": "Pipetting channel 2",
+    "P3": "Pipetting channel 3",
+    "P4": "Pipetting channel 4",
+    "P5": "Pipetting channel 5",
+    "P6": "Pipetting channel 6",
+    "P7": "Pipetting channel 7",
+    "P8": "Pipetting channel 8",
+    "P9": "Pipetting channel 9",
+    "PA": "Pipetting channel 10",
+    "PB": "Pipetting channel 11",
+    "PC": "Pipetting channel 12",
+    "PD": "Pipetting channel 13",
+    "PE": "Pipetting channel 14",
+    "PF": "Pipetting channel 15",
+    "PG": "Pipetting channel 16",
+    "H0": "CoRe 96 Head",
+    "HW": "Pump station 1 station",
+    "HU": "Pump station 2 station",
+    "HV": "Pump station 3 station",
+    "N0": "Nano dispenser",
+    "D0": "384 dispensing head",
+    "NP": "Nano disp. pressure controller",
+    "M1": "Reserved for module 1"
+  }[id_]
+
+
+def error_code_to_exception(code: int) -> HamiltonModuleError:
+  """ Convert an error code to an exception. """
+  codes = {
+    1: CommandSyntaxError,
+    2: HardwareError,
+    3: CommandNotCompletedError,
+    4: ClotDetectedError,
+    5: BarcodeUnreadableError,
+    6: TooLittleLiquidError,
+    7: TipAlreadyFittedError,
+    8: NoTipError,
+    9: NoCarrierError,
+    10: NotCompletedError,
+    11: DispenseWithPressureLLDError,
+    12: NoTeachInSignalError,
+    13: LoadingTrayError,
+    14: SequencedAspirationWithPressureLLDError,
+    15: NotAllowedParameterCombinationError,
+    16: CoverCloseError,
+    17: AspirationError,
+    18: WashFluidOrWasteError,
+    19: IncubationError,
+    20: TADMMeasurementError,
+    21: NoElementError,
+    22: ElementStillHoldingError,
+    23: ElementLostError,
+    24: IllegalTargetPlatePositionError,
+    25: IllegalUserAccessError,
+    26: TADMMeasurementError,
+    27: PositionNotReachableError,
+    28: UnexpectedLLDError,
+    29: AreaAlreadyOccupiedError,
+    30: ImpossibleToOccupyAreaError,
+    31: AntiDropControlError,
+    32: DecapperError,
+    33: DecapperHandlingError,
+    99: SlaveError,
+    100: WrongCarrierError,
+    101: NoCarrierBarcodeError,
+    102: LiquidLevelError,
+    103: NotDetectedError,
+    104: NotAspiratedError,
+    105: ImproperDispensationError,
+    106: NoLabwareError,
+    107: UnexpectedLabwareError,
+    108: WrongLabwareError,
+    109: BarcodeMaskError,
+    110: BarcodeNotUniqueError,
+    111: BarcodeAlreadyUsedError,
+    112: KitLotExpiredError,
+    113: DelimiterError
+  }
+  if code in codes:
+    return codes[code]
+  return UnknownHamiltonError
+
+
+class HamiltonError(Exception):
+  """ Base class for all Hamilton errors. """
+
+  pass
+
+
+class HamiltonFirmwareError(HamiltonError):
+  """
+  All Hamilton machine errors.
+
+  Example:
+    >>> try:
+    ...   lh.pickup_tips([True, True, True])
+    ... except HamiltonError as e:
+    ...   print(e)
+    HamiltonError({
+      'Pipetting Channel 1': NoTipError('Tip already picked up'),
+      'Pipetting Channel 3': NoTipError('Tip already picked up'),
+    })
+
+    >>> try:
+    ...   lh.pickup_tips([True, False, True])
+    ... except HamiltonError as e:
+    ...   if 'Pipetting Channel 1' in e:
+    ...     print('Pipetting Channel 1 error: ', e['Pipetting Channel 1'], e.error_code)
+    Pipetting Channel 1 error:  NoTipError('Tip already picked up'), '08/76'
+  """
+
+  def __init__(self, errors, raw_response=None):
+    self.raw_response = raw_response
+
+    # Convert error codes to error objects
+    self.errors = {}
+    for module_id, error in errors.items():
+      module_name = _module_id_to_module_name(module_id)
+      if "/" in error:
+        # C0 module: error code / trace information
+        error_code, trace_information = error.split("/")
+        error_code, trace_information = int(error_code), int(trace_information)
+        if error_code == 0: # No error
+          continue
+        error_class = error_code_to_exception(error_code)
+      else:
+        # Slave modules: er## (just trace information)
+        error_class = UnknownHamiltonError
+        trace_information = int(error)
+      error_description = HamiltonFirmwareError.trace_information_to_string(
+        module_identifier=module_id, trace_information=trace_information)
+      self.errors[module_name] = error_class(error_description,
+                                             raw_response=error,
+                                             raw_module=module_id)
+
+    # If the master error is a SlaveError, remove it from the errors dict.
+    if isinstance(self.errors.get("Master"), SlaveError):
+      self.errors.pop("Master")
+
+    super().__init__(str(self.errors))
+
+  def __len__(self):
+    return len(self.errors)
+
+  def __getitem__(self, key):
+    return self.errors[key]
+
+  def __setitem__(self, key, value):
+    self.errors[key] = value
+
+  def __contains__(self, key):
+    return key in self.errors
+
+  def items(self):
+    return self.errors.items()
+
+  @staticmethod
+  def trace_information_to_string(module_identifier: str, trace_information: int) -> str:
+    """ Convert a trace identifier to an error message. """
+    table = None
+
+    if module_identifier == "C0":
+      table = {
+        10: "CAN error",
+        11: "Slave command time out",
+        20: "E2PROM error",
+        30: "Unknown command",
+        31: "Unknown parameter",
+        32: "Parameter out of range",
+        33: "Parameter does not belong to command, or not all parameters were sent",
+        34: "Node name unknown",
+        35: "id parameter error",
+        37: "node name defined twice",
+        38: "faulty XL channel settings",
+        39: "faulty robotic channel settings",
+        40: "PIP task busy",
+        41: "Auto load task busy",
+        42: "Miscellaneous task busy",
+        43: "Incubator task busy",
+        44: "Washer task busy",
+        45: "iSWAP task busy",
+        46: "CoRe 96 head task busy",
+        47: "Carrier sensor doesn't work properly",
+        48: "CoRe 384 head task busy",
+        49: "Nano pipettor task busy",
+        50: "XL channel task busy",
+        51: "Tube gripper task busy",
+        52: "Imaging channel task busy",
+        53: "Robotic channel task busy"
+      }
+    elif module_identifier in ["PX", "P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", "P9", "PA",
+                               "PB", "PC", "PD", "PE", "PF", "PG"]:
+      table = {
+        0: "No error",
+        20: "No communication to EEPROM",
+        30: "Unknown command",
+        31: "Unknown parameter",
+        32: "Parameter out of range",
+        35: "Voltages outside permitted range",
+        36: "Stop during execution of command",
+        37: "Stop during execution of command",
+        40: "No paralell processes permitted (Two or more commands sent for the same control"
+            "process)",
+        50: "Dispensing drive init. position not found",
+        51: "Dispensing drive not initialized",
+        52: "Dispensing drive movement error",
+        53: "Maximum volume in tip reached",
+        54: "Position outside of permitted area",
+        55: "Y-drive blocked",
+        56: "Y-drive not initialized",
+        57: "Y-drive movement error",
+        60: "X-drive blocked",
+        61: "X-drive not initialized",
+        62: "X-drive movement error",
+        63: "X-drive limit stop not found",
+        70: "No liquid level found (possibly because no liquid was present)",
+        71: "Not enough liquid present (Immersion depth or surface following position possiby"
+            "below minimal access range)",
+        75: "No tip picked up, possibly because no was present at specified position",
+        76: "Tip already picked up",
+        77: "Tip not discarded",
+        78: "Wrong tip picked up",
+        80: "Liquid not correctly aspirated",
+        81: "Clot detected",
+        82: "TADM measurement out of lower limit curve",
+        83: "TADM measurement out of upper limit curve",
+        84: "Not enough memory for TADM measurement",
+        85: "No communication to digital potentiometer",
+        86: "ADC algorithm error",
+        87: "2nd phase of liquid nt found",
+        88: "Not enough liquid present (Immersion depth or surface following position possiby"
+            "below minimal access range)",
+        90: "Limit curve not resetable",
+        91: "Limit curve not programmable",
+        92: "Limit curve not found",
+        93: "Limit curve data incorrect",
+        94: "Not enough memory for limit curve",
+        95: "Invalid limit curve index",
+        96: "Limit curve already stored"
+      }
+
+    if table is not None and trace_information in table:
+      return table[trace_information]
+
+    return f"Unknown trace information code {trace_information:02}"
+
+
+class VENUSError(HamiltonError):
+  """ VENUS error """
+
+  pass
+
diff --git a/pyhamilton/liquid_handling/backends/simulation/__init__.py b/pyhamilton/liquid_handling/backends/simulation/__init__.py
new file mode 100644
index 0000000..c5aeadc
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/simulation/__init__.py
@@ -0,0 +1,3 @@
+""" The simulation allows you to view the actions of a liquid handler in your browser. """
+
+from .simulation import SimulationBackend
diff --git a/pyhamilton/liquid_handling/backends/simulation/simulation.py b/pyhamilton/liquid_handling/backends/simulation/simulation.py
new file mode 100644
index 0000000..0d8f13b
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/simulation/simulation.py
@@ -0,0 +1,466 @@
+""" Simulation """
+
+import asyncio
+from contextlib import suppress
+import http.server
+import json
+import logging
+import os
+import threading
+import time
+import typing
+import webbrowser
+
+try:
+  import websockets
+  HAS_WEBSOCKETS = True
+except ImportError:
+  print("Simulator requires websockets.") # TODO: change to runtime error.
+  HAS_WEBSOCKETS = False
+
+from pyhamilton.liquid_handling.backends import LiquidHandlerBackend
+from pyhamilton.liquid_handling.resources.abstract import Resource
+
+
+logger = logging.getLogger(__name__) # TODO: get from somewhere else?
+
+
+class SimulationBackend(LiquidHandlerBackend):
+  """ The simulator backend can be used to simulate robot methods and inspect the results in a
+  browser.
+
+  You can view the simulation at `http://localhost:1337 <http://localhost:1337>`_, where
+  `static/index.html` will be served.
+
+  The websocket server will run at `http://localhost:2121 <http://localhost:2121>`_ by default. If a
+  new browser page connects, it will replace the existing connection. All previously sent actions
+  will be sent to the new page, with no simualated delay, to ensure that the state of the simulation
+  remains the same. This also happens when a browser reloads the page or on the first page load.
+
+  Note that the simulator backend uses
+  :class:`~pyhamilton.liquid_handling.resources.abstract.Resource` 's to locate resources, where eg.
+  :class:`~pyhamilton.liquid_handling.backends.hamilton.STAR` uses absolute coordinates.
+
+  Examples:
+    Running a simple simulation:
+
+    >>> import pyhamilton.liquid_handling.backends.simulation.simulation as simulation
+    >>> from pyhamilton.liquid_handling.liquid_handler import LiquidHandler
+    >>> sb = simulation.SimulationBackend()
+    >>> lh = pyhamilton.liquid_handling.LiquidHandler(backend=sb)
+    >>> lh.setup()
+    INFO:websockets.server:server listening on 127.0.0.1:2121
+    INFO:pyhamilton.liquid_handling.backends.simulation.simulation:Simulation server started at
+      http://127.0.0.1:2121
+    INFO:pyhamilton.liquid_handling.backends.simulation.simulation:File server started at
+      http://127.0.0.1:1337
+    >>> lh.place_tips([[True]*12]*8)
+    >>> lh.pickup_tips(locations)
+  """
+
+  def __init__(
+    self,
+    simulate_delay: bool = False,
+    ws_host: str = "127.0.0.1",
+    ws_port: int = 2121,
+    fs_host: str = "127.0.0.1",
+    fs_port: int = 1337,
+    open_browser: bool = True,
+  ):
+    """ Create a new simulation backend.
+
+    Args:
+      simulate_delay: If `True`, the simulator will simulate the wait times for various actions,
+        otherwise actions will be instant.
+      ws_host: The hostname of the websocket server.
+      ws_port: The port of the websocket server. If this port is in use, the port will be
+        incremented until a free port is found.
+      fs_host: The hostname of the file server. This is where the simulation will be served.
+      fs_port: The port of the file server. If this port is in use, the port will be incremented
+        until a free port is found.
+      open_browser: If `True`, the simulation will open a browser window when it is started.
+    """
+
+    super().__init__()
+    self._resources = {}
+    self.websocket = None
+
+    self.simulate_delay = simulate_delay
+    self.ws_host = ws_host
+    self.ws_port = ws_port
+    self.fs_host = fs_host
+    self.fs_port = fs_port
+    self.open_browser = open_browser
+
+    self._sent_messages = []
+    self.received = []
+
+    self.stop_event = None
+
+    self._id = 0
+
+  def _generate_id(self):
+    """ continuously generate unique ids 0 <= x < 10000. """
+    self._id += 1
+    return f"{self._id % 10000:04}"
+
+  async def _socket_handler(self, websocket):
+    """ Handle a new websocket connection. Save the websocket connection store received
+    messages in `self.received`. """
+
+    while True:
+      try:
+        message = await websocket.recv()
+      except websockets.ConnectionClosed:
+        return
+      except asyncio.CancelledError:
+        return
+
+      data = json.loads(message)
+      self.received.append(data)
+
+      # If the event is "ready", then we can save the connection and send the saved messages.
+      if "event" in data and data["event"] == "ready":
+        self.websocket = websocket
+        await self._replay()
+
+        # Echo command
+        await websocket.send(json.dumps(data))
+
+  def send_event(
+    self,
+    event: str,
+    wait_for_response: bool = True,
+    **kwargs
+  )-> typing.Optional[dict]:
+    """ Send an event to the browser.
+
+    If a websocket connection has not been established, the event will be saved and sent when it is
+    established.
+
+    Args:
+      event: The event identifier.
+      wait_for_response: If `True`, the simulation will wait for a response from the browser. If
+        `False`, it is not guaranteed that the response will be available for reading at a later
+        time. This is useful for sending events that do not require a response. When `True`, a
+        `ValueError` will be raised if the response `"success"` field is not `True`.
+      **kwargs: The event arguments, which must be serializable by `json.dumps`.
+
+    Returns:
+      The response from the browser, if `wait_for_response` is `True`, otherwise `None`.
+    """
+
+    id_ = self._generate_id()
+    data = dict(event=event, id=id_, **kwargs)
+    data = json.dumps(data)
+    self._sent_messages.append(data)
+
+    # Run and save if the websocket connection has been established, otherwise just save.
+    if self.websocket is None and wait_for_response:
+      raise ValueError("Cannot wait for response when no websocket connection is established.")
+
+    if self.websocket is not None:
+      asyncio.run_coroutine_threadsafe(self.websocket.send(data), self.loop)
+
+      if wait_for_response:
+        while True:
+          if len(self.received) > 0:
+            message = self.received.pop()
+            if "id" in message and message["id"] == id_:
+              break
+          time.sleep(0.1)
+
+        if not message["success"]:
+          error = message.get("error", "unknown error")
+          raise ValueError(f"Error during event {event}: " + error)
+
+        return message
+
+  async def _replay(self):
+    """ Send all sent messages.
+
+    This is called when the websocket connection is established.
+    """
+
+    for message in self._sent_messages:
+      asyncio.run_coroutine_threadsafe(self.websocket.send(message), self.loop)
+
+  def setup(self):
+    """ Setup the simulation.
+
+    Sets up the websocket server. This will run in a separate thread.
+    """
+
+    if not HAS_WEBSOCKETS:
+      raise RuntimeError("The simulator requires websockets to be installed.")
+
+    super().setup()
+
+    async def run_server():
+      self.stop_ = self.loop.create_future()
+      while True:
+        try:
+          async with websockets.serve(self._socket_handler, self.ws_host, self.ws_port):
+            print("Simulation server started at http://%s:%s", self.ws_host, self.ws_port)
+            # logger.info("Simulation server started at http://%s:%s", self.ws_host, self.ws_port)
+            await self.stop_
+            break
+        except asyncio.CancelledError:
+          pass
+        except OSError:
+          # If the port is in use, try the next port.
+          self.ws_port += 1
+
+    loop = asyncio.new_event_loop()
+    self.t = threading.Thread(target=loop.run_forever)
+    self.t.start()
+    self.loop = loop
+
+    asyncio.run_coroutine_threadsafe(run_server(), self.loop)
+
+    self._run_file_server()
+
+  def _run_file_server(self):
+    """ Start a simple webserver to serve static files. """
+
+    dirname = os.path.dirname(__file__)
+    path = os.path.join(dirname, "simulator")
+    if not os.path.exists(path):
+      raise RuntimeError("Could not find simulation files. Please run from the root of the "
+                         "repository.")
+
+    def start_server():
+      # try to start the server. If the port is in use, try with another port until it succeeds.
+      os.chdir(path) # only within thread.
+
+      class QuietSimpleHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
+        """ A simple HTTP request handler that does not log requests. """
+        def log_message(self, format, *args):
+          pass
+
+      while True:
+        try:
+          self.httpd = http.server.HTTPServer((self.fs_host, self.fs_port),
+            QuietSimpleHTTPRequestHandler)
+          logger.info("File server started at http://%s:%s", self.fs_host, self.fs_port)
+          break
+        except OSError:
+          self.fs_port += 1
+
+      self.httpd.serve_forever()
+
+    self.fst = threading.Thread(name="simulation_fs", target=start_server)
+    self.fst.setDaemon(True)
+    self.fst.start()
+
+    if self.open_browser:
+      webbrowser.open(f"http://{self.fs_host}:{self.fs_port}")
+
+  def stop(self):
+    """ Stop the simulation. """
+
+    super().stop()
+
+    if self.loop is None:
+      raise ValueError("Cannot stop simulation when it has not been started.")
+
+    # send stop event to the browser
+    self.send_event("stop", wait_for_response=False)
+
+    # stop server, graceful
+    self.stop_.set_result("done")
+
+    async def cancel_handler():
+      for task in asyncio.all_tasks(loop=self.loop):
+        task.cancel()
+        with suppress(asyncio.CancelledError):
+          await task
+
+      self.loop.stop()
+      lock.release()
+
+    # Lock to prevent the loop from exiting while we're waiting for the cancel to complete.
+    lock = threading.Lock()
+    lock.acquire() # pylint: disable=consider-using-with
+
+    # Cancel all pending tasks, wait for them to complete, and then stop the loop.
+    asyncio.run_coroutine_threadsafe(cancel_handler(), loop=self.loop)
+
+    # While the loop is still running, wait for it to stop.
+    while lock.locked():
+      pass
+
+    # Stop the file server.
+    self.httpd.shutdown()
+    self.httpd.server_close()
+
+    # Clear all relevant attributes.
+    self._sent_messages.clear()
+    self.received.clear()
+    self.websocket = None
+    self.loop = None
+    self.t = None
+    self.stop_ = None
+    self.httpd = None
+    self.fst = None
+
+  def assigned_resource_callback(self, resource):
+    self.send_event(event="resource_assigned", resource=resource.serialize(),
+      wait_for_response=False)
+
+  def unassigned_resource_callback(self, name):
+    self.send_event(event="resource_unassigned", resource_name=name, wait_for_response=False)
+
+  def pickup_tips(
+    self,
+    resource,
+    channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+  ):
+    channels = {
+      "channel_1": channel_1, "channel_2": channel_2, "channel_3": channel_3,
+      "channel_4": channel_4, "channel_5": channel_5, "channel_6": channel_6,
+      "channel_7": channel_7, "channel_8": channel_8,
+    }
+    self.send_event(event="pickup_tips", resource=resource.serialize(), channels=channels,
+      wait_for_response=True)
+
+  def discard_tips(
+    self,
+    resource,
+    channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+  ):
+    channels = {
+      "channel_1": channel_1, "channel_2": channel_2, "channel_3": channel_3,
+      "channel_4": channel_4, "channel_5": channel_5, "channel_6": channel_6,
+      "channel_7": channel_7, "channel_8": channel_8,
+    }
+    self.send_event(event="discard_tips", resource=resource.serialize(), channels=channels,
+      wait_for_response=True)
+
+  def aspirate(
+    self,
+    resource,
+    channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+  ):
+    # Serialize channels.
+    channels = {}
+    for i, channel in enumerate([channel_1, channel_2, channel_3, channel_4,
+                                 channel_5, channel_6, channel_7, channel_8]):
+      if channel is not None:
+        channels[f"channel_{i+1}"] = channel.serialize()
+    self.send_event(event="aspirate", resource=resource.serialize(), channels=channels,
+      wait_for_response=True)
+
+  def dispense(
+    self,
+    resource,
+    channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+  ):
+    # Serialize channels.
+    channels = {}
+    for i, channel in enumerate([channel_1, channel_2, channel_3, channel_4,
+                                 channel_5, channel_6, channel_7, channel_8]):
+      if channel is not None:
+        channels[f"channel_{i+1}"] = channel.serialize()
+    self.send_event(event="dispense", resource=resource.serialize(), channels=channels,
+      wait_for_response=True)
+
+  def pickup_tips96(self, resource):
+    self.send_event(event="pickup_tips96", resource=resource.serialize(), wait_for_response=True)
+
+  def discard_tips96(self, resource):
+    self.send_event(event="discard_tips96", resource=resource.serialize(),
+      wait_for_response=True)
+
+  def aspirate96(self, resource, pattern, volume):
+    pattern = [[(volume if p else 0) for p in pattern[i]] for i in range(len(pattern))]
+    self.send_event(event="aspirate96", resource=resource.serialize(), pattern=pattern,
+      volume=volume, wait_for_response=True)
+
+  def dispense96(self, resource, pattern, volume):
+    pattern = [[volume if p else 0 for p in pattern[i]] for i in range(len(pattern))]
+    self.send_event(event="dispense96", resource=resource.serialize(), pattern=pattern,
+      volume=volume, wait_for_response=True)
+
+  # -------------- Simulator only methods --------------
+
+  def adjust_well_volume(self, resource: Resource, pattern: typing.List[typing.List[float]]):
+    """ Fill a resource with liquid (**simulator only**).
+
+    Simulator method to fill a resource with liquid, for testing of liquid handling.
+
+    Args:
+      resource: The resource to fill.
+      pattern: A list of lists of liquid volumes to fill the resource with.
+
+    Raises:
+      RuntimeError: if this method is called before :func:`~setup`.
+    """
+
+    # Check if set up has been run, else raise a ValueError.
+    if not self.setup_finished:
+      raise RuntimeError("The setup has not been finished.")
+
+    self.send_event(event="adjust_well_volume", resource=resource.serialize(), pattern=pattern,
+     wait_for_response=True)
+
+  def place_tips(self, resource: Resource, pattern: typing.List[typing.List[bool]]):
+    """ Place tips on the robot (**simulator only**).
+
+    Simulator method to place tips on the robot, for testing of tip pickup/discarding. Unlike,
+    :func:`~Simulator.pickup_tips`, this method does not raise an exception if tips are already
+    present on the specified locations. Note that a
+    :class:`~pyhamilton.liquid_handling.resources.abstract.Tips` resource has to be assigned.
+
+    Args:
+      resource: The resource to place tips in.
+      pattern: A list of lists of places where to place a tip.
+
+    Raises:
+      RuntimeError: if this method is called before :func:`~setup`.
+    """
+
+    self.send_event(event="edit_tips", resource=resource.serialize(), pattern=pattern,
+      wait_for_response=True)
+
+  def fill_tips(self, resource: Resource):
+    """ Completely fill a :class:`~pyhamilton.liquid_handling.resources.abstract.Tips` resource with
+    tips. (**simulator only**).
+
+    Args:
+      resource: The resource where all tips should be placed.
+    """
+
+    self.place_tips(resource, [[True] * 12] * 8)
+
+  def remove_tips(self, resource: Resource, pattern: typing.List[typing.List[bool]]):
+    """ Remove tips from the robot (**simulator only**).
+
+    Simulator method to remove tips from the robot, for testing of tip pickup/discarding. Unlike,
+    :func:`~Simulator.pickup_tips`, this method does not raise an exception if tips are not
+    present on the specified locations. Note that a
+    :class:`~pyhamilton.liquid_handling.resources.abstract.Tips` resource has to be assigned.
+
+    Args:
+      resource: The resource to remove tips from.
+      pattern: A list of lists of places where to remove a tip.
+
+    Raises:
+      RuntimeError: if this method is called before :func:`~setup`.
+    """
+
+    # Flip each boolean in the 2d array.
+    pattern = [[not p for p in pattern[i]] for i in range(len(pattern))]
+
+    self.send_event(event="edit_tips", resource=resource.serialize(), pattern=pattern,
+      wait_for_response=True)
+
+  def clear_tips(self, resource: Resource):
+    """ Completely clear a :class:`~pyhamilton.liquid_handling.resources.abstract.Tips` resource.
+    (**simulator only**).
+
+    Args:
+      resource: The resource where all tips should be removed.
+    """
+
+    self.remove_tips(resource, [[True] * 12] * 8)
diff --git a/pyhamilton/liquid_handling/backends/simulation/simulator/index.html b/pyhamilton/liquid_handling/backends/simulation/simulator/index.html
new file mode 100644
index 0000000..a5d521a
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/simulation/simulator/index.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>PyHamilton Simulator</title>
+    <link
+      rel="stylesheet"
+      href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css"
+    />
+    <link
+      href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
+      rel="stylesheet"
+      integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
+      crossorigin="anonymous"
+    />
+
+    <script src="https://unpkg.com/konva@8/konva.min.js"></script>
+
+    <link href="/main.css" rel="stylesheet"></link>
+  </head>
+  <body>
+    <nav class="navbar bg-light">
+      <div class="container-fluid">
+        <a class="navbar-brand" href="#">
+          PyHamilton Simulator <span class="fs-6 text-muted">(v0.1)</span></a
+        >
+        <div class="ml-auto">
+          <p onclick="openSocket();"><span id="status-indicator"></span> <span id="status-label">Loading...</span></p>
+        </div>
+      </div>
+    </nav>
+
+    <div class="row content">
+      <main class="col-12">
+        <div id="kanvas"></div>
+      </main>
+    </div>
+
+    <script src="/main.js"></script>
+  </body>
+</html>
diff --git a/pyhamilton/liquid_handling/backends/simulation/simulator/main.css b/pyhamilton/liquid_handling/backends/simulation/simulator/main.css
new file mode 100644
index 0000000..cdeb180
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/simulation/simulator/main.css
@@ -0,0 +1,50 @@
+* {
+  box-sizing: border-box;
+  padding: 0;
+  margin: 0;
+}
+
+.navbar {
+  height: 50px;
+}
+
+.content {
+  height: calc(100vh - 50px);
+  margin: 0;
+}
+
+main {
+  padding: 0 !important;
+}
+
+#canvas {
+  display: block;
+}
+
+#kanvas {
+  width: 100%;
+  height: 100%;
+}
+
+#status-indicator {
+  height: 10px;
+  width: 10px;
+  border-radius: 50%;
+  display: inline-block;
+  background-color: gray;
+}
+#status-label {
+  color: gray;
+}
+#status-indicator.connected {
+  background-color: green;
+}
+#status-indicator.disconnected {
+  background-color: red;
+}
+#status-label.connected {
+  color: green;
+}
+#status-label.disconnected {
+  color: red;
+}
\ No newline at end of file
diff --git a/pyhamilton/liquid_handling/backends/simulation/simulator/main.js b/pyhamilton/liquid_handling/backends/simulation/simulator/main.js
new file mode 100644
index 0000000..062156f
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/simulation/simulator/main.js
@@ -0,0 +1,804 @@
+// Simulator.js
+
+// ===========================================================================
+// UI tools
+// ===========================================================================
+
+const robotWidthMM = 30 * 22.5; // mm, just the deck
+const robotHeightMM = 497; // mm
+
+var layer = new Konva.Layer();
+var resourceLayer = new Konva.Layer();
+var tooltipLayer = new Konva.Layer();
+var tooltip;
+var stage;
+
+var canvasWidth, canvasHeight;
+
+const numRails = 30;
+
+var resources = {};
+
+const plateColor = "#2B2D42";
+
+function locationToPixel(location) {
+  return {
+    x: (location.x * canvasWidth) / robotWidthMM,
+    y: (location.y * canvasHeight) / robotHeightMM,
+  };
+}
+
+function robotWidthToScreenWidth(robotWidth) {
+  return (robotWidth * canvasWidth) / robotWidthMM;
+}
+
+function robotHeightToScreenHeight(robotHeight) {
+  return (robotHeight * canvasHeight) / robotHeightMM;
+}
+
+var tips = {};
+var wells = {};
+
+// Initialize pipetting heads.
+var pipHead = []; // [{has_tip: bool, volume: float, tipMaxVolume: float}]
+for (var i = 0; i < 8; i++) {
+  pipHead.push({ has_tip: false, volume: 0 });
+}
+var CoRe96Head = []; // [[{has_tip: bool, volume: float, tipMaxVolume: float}]]
+for (var i = 0; i < 8; i++) {
+  CoRe96Head[i] = [];
+  for (var j = 0; j < 12; j++) {
+    CoRe96Head[i].push({ has_tip: false, volume: 0, tipMaxVolume: 0 });
+  }
+}
+
+const statusLabel = document.getElementById("status-label");
+const statusIndicator = document.getElementById("status-indicator");
+function updateStatusLabel(status) {
+  if (status === "loaded") {
+    statusLabel.innerText = "Connected";
+    statusLabel.classList.add("connected");
+    statusLabel.classList.remove("disconnected");
+    statusIndicator.classList.add("connected");
+    statusIndicator.classList.remove("disconnected");
+  } else if (status === "disconnected") {
+    statusLabel.innerText = "Disconnected";
+    statusLabel.classList.add("disconnected");
+    statusLabel.classList.remove("connected");
+    statusIndicator.classList.add("disconnected");
+    statusIndicator.classList.remove("connected");
+  } else {
+    statusLabel.innerText = "Loading...";
+    statusLabel.classList.remove("connected");
+    statusLabel.classList.remove("disconnected");
+    statusIndicator.classList.remove("connected");
+    statusIndicator.classList.remove("disconnected");
+  }
+}
+
+function drawCarrier(resource) {}
+
+function addTooltipOnMouseMove(text) {
+  var mousePos = stage.getPointerPosition();
+  tooltip.position({
+    x: mousePos.x + 5,
+    // y: canvasHeight - (mousePos.y + 5),
+    y: mousePos.y + 5,
+  });
+  tooltip.getText().text(text);
+  tooltip.show();
+}
+
+function addTooltipWell(well, row, column, resource) {
+  const letter = String.fromCharCode(65 + row);
+
+  well.on("mousemove", () => {
+    addTooltipOnMouseMove(
+      `${resource.name} (${letter}${column + 1}) (${
+        wells[resource.name][row][column].volume
+      }uL)`
+    );
+  });
+
+  well.on("mouseout", function () {
+    tooltip.hide();
+  });
+}
+
+function addTooltipTip(tip, row, column, resource) {
+  const letter = String.fromCharCode(65 + row);
+
+  tip.on("mousemove", () => {
+    addTooltipOnMouseMove(
+      `${resource.name} (${letter}${column + 1}) (${
+        tips[resource.name][row][column].has_tip ? "has" : "no"
+      } tip)`
+    );
+  });
+
+  tip.on("mouseout", function () {
+    tooltip.hide();
+  });
+}
+
+function drawPlate(resource, resourceLocation, group) {
+  // Draw plate background.
+  var plate = new Konva.Rect({
+    x: resourceLocation.x,
+    y: resourceLocation.y,
+    width: robotWidthToScreenWidth(resource.size_x),
+    height: robotHeightToScreenHeight(resource.size_y),
+    fill: plateColor,
+    stroke: "black",
+    strokeWidth: 1,
+  });
+  group.add(plate);
+
+  // Initialize empty well objects.
+  wells[resource.name] = {};
+  for (var i = 0; i < 8; i++) {
+    wells[resource.name][i] = {};
+    for (var j = 0; j < 12; j++) {
+      wells[resource.name][i][j] = {};
+    }
+  }
+
+  // Draw wells.
+  for (let i = 0; i < 8; i++) {
+    for (let j = 0; j < 12; j++) {
+      // Fit n+1 wells in the row/column.
+      var well = new Konva.Circle({
+        x:
+          resourceLocation.x +
+          ((j + 1) * robotWidthToScreenWidth(resource.size_x) - 0) / (12 + 1),
+        y:
+          resourceLocation.y +
+          ((8 - i) * robotHeightToScreenHeight(resource.size_y) - 20) / (8 + 1),
+        width: 10,
+        height: 10,
+        stroke: "#D80032",
+        strokeWidth: 1,
+      });
+
+      const volume = 0;
+      const maxVolume = 1000; // TODO: get well volume from server.
+      wells[resource.name][i][j] = {
+        maxVolume: maxVolume,
+        shape: well,
+        volume: volume,
+      };
+
+      adjustVolumeSingleWell(resource, i, j, volume);
+
+      addTooltipWell(well, i, j, resource);
+
+      group.add(well);
+    }
+  }
+}
+
+function drawTips(resource, resourceLocation, group) {
+  // Draw tips background.
+  var tipsShape = new Konva.Rect({
+    x: resourceLocation.x,
+    y: resourceLocation.y,
+    width: robotWidthToScreenWidth(resource.size_x),
+    height: robotHeightToScreenHeight(resource.size_y),
+    fill: plateColor,
+    stroke: "black",
+    strokeWidth: 1,
+  });
+  group.add(tipsShape);
+
+  // Initialize empty tip objects.
+  tips[resource.name] = {};
+  for (var i = 0; i < 8; i++) {
+    tips[resource.name][i] = {};
+    for (var j = 0; j < 12; j++) {
+      tips[resource.name][i][j] = {};
+    }
+  }
+
+  // Draw tips.
+  for (let i = 0; i < 8; i++) {
+    for (let j = 0; j < 12; j++) {
+      var tip = new Konva.Circle({
+        x:
+          resourceLocation.x +
+          ((j + 1) * robotWidthToScreenWidth(resource.size_x) - 0) / (12 + 1),
+        y:
+          resourceLocation.y +
+          ((8 - i) * robotHeightToScreenHeight(resource.size_y) - 20) / (8 + 1),
+        width: 10,
+        height: 10,
+        fill: noTipsColor,
+        stroke: "#1ee172",
+        strokeWidth: 1,
+      });
+
+      tips[resource.name][i][j] = {
+        tip: tip,
+        has_tip: false,
+      };
+      addTooltipTip(tip, i, j, resource);
+
+      group.add(tip);
+    }
+  }
+}
+
+// ===========================================================================
+// event handling
+// ===========================================================================
+
+function drawResource(resource) {
+  const location = locationToPixel({
+    x: resource.location.x - 100,
+    y: resource.location.y - 63,
+  });
+
+  var group = new Konva.Group({
+    x: location.x,
+    y: location.y,
+  });
+
+  var carrier = new Konva.Rect({
+    x: 0,
+    y: 0,
+    width: robotWidthToScreenWidth(resource.size_x),
+    height: robotHeightToScreenHeight(resource.size_y),
+    fill: "#8D99AE",
+    stroke: "black",
+    strokeWidth: 1,
+  });
+
+  group.add(carrier);
+
+  // If resource is a carrier, draw the sites too.
+  for (var i = 0; i < resource.sites.length; i++) {
+    const site = resource.sites[i].site;
+    const location = locationToPixel(site.location);
+
+    // If the site has a resource, draw that too.
+    if (site.resource) {
+      if (site.resource.category === "plate") {
+        drawPlate(site.resource, location, group);
+      } else if (site.resource.category === "tips") {
+        drawTips(site.resource, location, group);
+      }
+    } else {
+      // If the site is empty, draw a circle.
+      var emptySite = new Konva.Rect({
+        x: location.x,
+        y: location.y,
+        width: robotWidthToScreenWidth(site.width),
+        height: robotHeightToScreenHeight(site.height),
+        fill: plateColor,
+        stroke: "black",
+        opacity: 0.5,
+        strokeWidth: 1,
+      });
+      group.add(emptySite);
+    }
+  }
+
+  // TODO: should we add subresources, plates in particular, as separate resources?
+  resources[resource.name] = group;
+  layer.add(group);
+}
+
+function removeResource(resource_name) {
+  if (resource_name in resources) {
+    resources[resource_name].destroy();
+    delete resources[resource_name];
+  }
+}
+
+function colorForVolume(volume, maxVolume) {
+  return `rgba(239, 35, 60, ${volume / maxVolume})`;
+}
+
+function adjustVolumeSingleWell(resource, row, column, volume) {
+  const well = wells[resource.name][row][column];
+  well.volume = volume;
+  const newColor = colorForVolume(volume, well.maxVolume);
+  well.shape.fill(newColor);
+}
+
+function adjustVolume(resource, pattern) {
+  for (let i = 0; i < pattern.length; i++) {
+    for (let j = 0; j < pattern[i].length; j++) {
+      adjustVolumeSingleWell(resource, i, j, pattern[i][j]);
+    }
+  }
+  return null;
+}
+
+const hasTipsColor = "#40CDA1";
+const noTipsColor = plateColor;
+
+// A1 -> 0, 0; B2 -> 1, 1; C3 -> 2, 2; etc.
+function positionToCoordinate(position) {
+  const row = position.charCodeAt(0) - 65;
+  const column = position.slice(1) - 1;
+  return { row, column };
+}
+
+// Returns error message if there is a problem, otherwise returns null.
+function pickUpTips(resource, channels) {
+  // channel locations
+  channels = [
+    channels["channel_1"],
+    channels["channel_2"],
+    channels["channel_3"],
+    channels["channel_4"],
+    channels["channel_5"],
+    channels["channel_6"],
+    channels["channel_7"],
+    channels["channel_8"],
+  ];
+  for (let i = 0; i < channels.length; i++) {
+    let location = channels[i];
+
+    // Skip if the channel is empty: in that case, no tip should be picked up.
+    if (location === null) {
+      continue;
+    }
+
+    const { row, column } = positionToCoordinate(location);
+
+    const tip = tips[resource.name][row][column];
+    if (!tip.has_tip) {
+      return `No tip at location ${location}.`;
+    }
+    if (pipHead[i].has_tip) {
+      return `Pip head channel ${i + 1} already has a tip.`;
+    }
+
+    tip.has_tip = false;
+    tip.tip.fill(noTipsColor);
+    pipHead[i].has_tip = true;
+    pipHead[i].tipMaxVolume = resource.tip_type.maximal_volume;
+  }
+  return null;
+}
+
+// Returns error message if there is a problem, otherwise returns null.
+function discardTips(resource, channels) {
+  // channel locations
+  channels = [
+    channels["channel_1"],
+    channels["channel_2"],
+    channels["channel_3"],
+    channels["channel_4"],
+    channels["channel_5"],
+    channels["channel_6"],
+    channels["channel_7"],
+    channels["channel_8"],
+  ];
+  for (let i = 0; i < channels.length; i++) {
+    let location = channels[i];
+
+    // Skip if the channel is empty: in that case, no tip should be picked up.
+    if (location === null) {
+      continue;
+    }
+
+    const { row, column } = positionToCoordinate(location);
+
+    const tip = tips[resource.name][row][column];
+    if (tip.has_tip) {
+      return `There already is tip at location ${location}.`;
+    }
+    if (!pipHead[i].has_tip) {
+      return `Pip head channel ${i + 1} already does not have a tip.`;
+    }
+    if (pipHead[i].volume > 0) {
+      return `Pip head channel ${i + 1} has a volume of ${
+        pipHead[i].volume
+      }uL > 0`;
+    }
+
+    tip.has_tip = true;
+    tip.tip.fill(hasTipsColor);
+    pipHead[i].has_tip = false;
+    pipHead[i].tipMaxVolume = undefined;
+  }
+  return null;
+}
+
+function editTips(resource, pattern) {
+  for (let i = 0; i < pattern.length; i++) {
+    for (let j = 0; j < pattern[i].length; j++) {
+      tips[resource.name][i][j].tip.fill(
+        pattern[i][j] ? hasTipsColor : noTipsColor
+      );
+      tips[resource.name][i][j].has_tip = pattern[i][j];
+    }
+  }
+  return null;
+}
+
+function aspirate(resource, channels) {
+  var channels = [
+    channels["channel_1"],
+    channels["channel_2"],
+    channels["channel_3"],
+    channels["channel_4"],
+    channels["channel_5"],
+    channels["channel_6"],
+    channels["channel_7"],
+    channels["channel_8"],
+  ];
+  for (let i = 0; i < channels.length; i++) {
+    let info = channels[i];
+
+    // Skip if the channel is empty.
+    if (info === null || info === undefined) {
+      continue;
+    }
+
+    const { row, column } = positionToCoordinate(info.position);
+    const well = wells[resource.name][row][column];
+
+    if (well.volume < info.volume) {
+      return `Not enough volume in well: ${well.volume}uL.`;
+    }
+
+    if (!pipHead[i].has_tip) {
+      return `Pip head channel ${i + 1} does not have a tip.`;
+    }
+
+    if (info.volume + pipHead[i].volume > pipHead[i].tipMaxVolume) {
+      return `Aspirated volume (${info.volume}uL) + volume of tip (${pipHead[i].volume}uL) > maximal volume of tip (${pipHead[i].tipMaxVolume}uL).`;
+    }
+
+    pipHead[i].volume += info.volume;
+    adjustVolumeSingleWell(resource, row, column, well.volume - info.volume);
+  }
+  return null;
+}
+
+function dispense(resource, channels) {
+  var channels = [
+    channels["channel_1"],
+    channels["channel_2"],
+    channels["channel_3"],
+    channels["channel_4"],
+    channels["channel_5"],
+    channels["channel_6"],
+    channels["channel_7"],
+    channels["channel_8"],
+  ];
+  for (let i = 0; i < channels.length; i++) {
+    let info = channels[i];
+
+    // Skip if the channel is empty.
+    if (info === null || info === undefined) {
+      continue;
+    }
+
+    const { row, column } = positionToCoordinate(info.position);
+    const well = wells[resource.name][row][column];
+
+    if (pipHead[i].volume < info.volume) {
+      return `Not enough volume in tip: ${pipHead[i].volume}.`;
+    }
+
+    if (!pipHead[i].has_tip) {
+      return `Pip head channel ${i + 1} does not have a tip.`;
+    }
+
+    if (info.volume + well.volume > well.maxVolume) {
+      return `Dispensed volume (${info.volume}uL) + volume of well (${well.volume}uL) > maximal volume of well (${well.maxVolume}uL).`;
+    }
+
+    pipHead[i].volume -= info.volume;
+    adjustVolumeSingleWell(resource, row, column, well.volume + info.volume);
+  }
+  return null;
+}
+
+function pickupTips96(resource) {
+  // Validate there are enough tips first, and that there are no tips in the head.
+  for (let i = 0; i < 8; i++) {
+    for (let j = 0; j < 12; j++) {
+      if (!tips[resource.name][i][j].has_tip) {
+        return `There is no tip at (${i},${j}) in ${resource.name}.`;
+      }
+      if (CoRe96Head[i][j].has_tip) {
+        return `There already is a tip in the CoRe 96 head at (${i},${j}) in ${resource.name}.`;
+      }
+    }
+  }
+
+  // Then pick up the tips.
+  for (let i = 0; i < 8; i++) {
+    for (let j = 0; j < 12; j++) {
+      const tip = tips[resource.name][i][j];
+      tip.has_tip = false;
+      tip.tip.fill(noTipsColor);
+      CoRe96Head[i][j].has_tip = true;
+      CoRe96Head[i][j].tipMaxVolume = resource.tip_type.maximal_volume;
+    }
+  }
+}
+
+function discardTips96(resource) {
+  // Validate there are enough tips first, and that there are no tips in the head.
+  for (let i = 0; i < 8; i++) {
+    for (let j = 0; j < 12; j++) {
+      if (tips[resource.name][i][j].has_tip) {
+        return `There already is a tip at (${i},${j}) in ${resource.name}.`;
+      }
+      if (!CoRe96Head[i][j].has_tip) {
+        return `There is no tip in the CoRe 96 head at (${i},${j}) in ${resource.name}.`;
+      }
+    }
+  }
+
+  // Then pick up the tips.
+  for (let i = 0; i < 8; i++) {
+    for (let j = 0; j < 12; j++) {
+      const tip = tips[resource.name][i][j];
+      tip.has_tip = true;
+      tip.tip.fill(hasTipsColor);
+      CoRe96Head[i][j].has_tip = false;
+      CoRe96Head[i][j].tipMaxVolume = undefined;
+    }
+  }
+}
+
+function aspirate96(resource, pattern) {
+  // Validate there is enough liquid available, that it fits in the tips, and that each channel
+  // has a tip before aspiration.
+  for (let i = 0; i < pattern.length; i++) {
+    for (let j = 0; j < pattern[i].length; j++) {
+      if (pattern[i][j] > 0) {
+        const well = wells[resource.name][i][j];
+        if (well.volume < pattern[i][j]) {
+          return `Not enough volume in well: ${well.volume}uL.`;
+        }
+        if (
+          CoRe96Head[i][j].volume + pattern[i][j] >
+          CoRe96Head[i][j].maxVolume
+        ) {
+          return `Aspirated volume (${pattern[i][j]}uL) + volume of tip (${CoRe96Head[i][j].volume}uL) > maximal volume of tip (${CoRe96Head[i][j].maxVolume}uL).`;
+        }
+        if (!CoRe96Head[i][j].has_tip) {
+          return `CoRe 96 head channel (${i},${j}) does not have a tip.`;
+        }
+      }
+    }
+  }
+
+  for (let i = 0; i < pattern.length; i++) {
+    for (let j = 0; j < pattern[i].length; j++) {
+      const well = wells[resource.name][i][j];
+      CoRe96Head[i][j].volume += pattern[i][j];
+      adjustVolumeSingleWell(resource, i, j, well.volume - pattern[i][j]);
+    }
+  }
+
+  return null;
+}
+
+function dispense96(resource, pattern) {
+  // Validate there is enough liquid available, that it fits in the well, and that each channel
+  // has a tip before dispense.
+  for (let i = 0; i < pattern.length; i++) {
+    for (let j = 0; j < pattern[i].length; j++) {
+      if (pattern[i][j] > 0) {
+        const well = wells[resource.name][i][j];
+        if (CoRe96Head[i][j].volume < pattern[i][j]) {
+          return `Not enough volume in head: ${CoRe96Head[i][j].volume}uL.`;
+        }
+        if (well.volume + pattern[i][j] > well.maxVolume) {
+          return `Dispensed volume (${pattern[i][j]}uL) + volume of well (${well.volume}uL) > maximal volume of well (${well.maxVolume}uL).`;
+        }
+        if (!CoRe96Head[i][j].has_tip) {
+          return `CoRe 96 head channel (${i},${j}) does not have a tip.`;
+        }
+      }
+    }
+  }
+
+  for (let i = 0; i < pattern.length; i++) {
+    for (let j = 0; j < pattern[i].length; j++) {
+      const well = wells[resource.name][i][j];
+      CoRe96Head[i][j].volume -= pattern[i][j];
+      adjustVolumeSingleWell(resource, i, j, well.volume + pattern[i][j]);
+    }
+  }
+
+  return null;
+}
+
+function handleEvent(event, data) {
+  if (event === "ready") {
+    return; // don't parse response.
+  }
+
+  var resource = undefined;
+  if (data.resource) {
+    resource = data.resource;
+  }
+
+  const ret = {
+    event: event,
+    id: data.id,
+  };
+
+  console.log("[event] " + event, data);
+
+  switch (event) {
+    case "resource_assigned":
+      drawResource(resource);
+      break;
+
+    case "resource_unassigned":
+      removeResource(data.resource_name);
+      break;
+
+    case "pickup_tips":
+      ret.error = pickUpTips(resource, data.channels);
+      break;
+
+    case "discard_tips":
+      ret.error = discardTips(resource, data.channels);
+      break;
+
+    case "edit_tips":
+      ret.error = editTips(resource, data.pattern);
+      break;
+
+    case "adjust_well_volume":
+      ret.error = adjustVolume(resource, data.pattern);
+      break;
+
+    case "aspirate":
+      ret.error = aspirate(resource, data.channels);
+      break;
+
+    case "dispense":
+      ret.error = dispense(resource, data.channels);
+      break;
+
+    case "pickup_tips96":
+      ret.error = pickupTips96(resource);
+      break;
+
+    case "discard_tips96":
+      ret.error = discardTips96(resource);
+      break;
+
+    case "aspirate96":
+      ret.error = aspirate96(resource, data.pattern);
+      break;
+
+    case "dispense96":
+      ret.error = dispense96(resource, data.pattern);
+      break;
+
+    default:
+      ret.error = "Unknown event";
+      break;
+  }
+
+  if (ret.error === undefined || ret.error === null) {
+    ret.success = true;
+    delete ret.error;
+  } else {
+    ret.success = false;
+  }
+
+  webSocket.send(JSON.stringify(ret));
+}
+
+// ===========================================================================
+// init
+// ===========================================================================
+
+var socketLoading = false;
+function openSocket() {
+  if (socketLoading) {
+    return;
+  }
+
+  socketLoading = true;
+  updateStatusLabel("loading");
+  webSocket = new WebSocket(`ws://localhost:2121/`);
+
+  webSocket.onopen = function (event) {
+    console.log("Connected to " + event.target.URL);
+    webSocket.send(`{"event": "ready"}`);
+    updateStatusLabel("loaded");
+    socketLoading = false;
+  };
+
+  webSocket.onerror = function () {
+    updateStatusLabel("disconnected");
+    socketLoading = false;
+  };
+
+  webSocket.onclose = function () {
+    updateStatusLabel("disconnected");
+    socketLoading = false;
+  };
+
+  // webSocket.onmessage = function (event) {
+  webSocket.addEventListener("message", function (event) {
+    var data = event.data;
+    data = JSON.parse(data);
+    console.log(`[message] Data received from server:`, data);
+    handleEvent(data.event, data);
+  });
+}
+
+function drawRails() {
+  for (var i = 1; i < numRails; i++) {
+    const x = (canvasWidth / numRails) * i;
+    const line = new Konva.Line({
+      points: [x, 0, x, canvasHeight],
+      stroke: "black",
+      strokeWidth: 1,
+    });
+    layer.add(line);
+  }
+}
+
+function drawTooltip() {
+  tooltip = new Konva.Label({
+    x: 0,
+    y: 0,
+    opacity: 1,
+    visible: false,
+  });
+
+  tooltip.add(
+    new Konva.Tag({
+      fill: "yellow",
+    })
+  );
+
+  tooltip.add(
+    new Konva.Text({
+      text: "",
+      fontFamily: "Arial",
+      fontSize: 14,
+      padding: 5,
+      fill: "black",
+    })
+  );
+
+  tooltipLayer.add(tooltip);
+}
+
+window.addEventListener("load", function () {
+  const canvas = document.getElementById("kanvas");
+  canvasWidth = canvas.offsetWidth;
+  canvasHeight = canvas.offsetHeight;
+
+  stage = new Konva.Stage({
+    container: "kanvas",
+    width: canvasWidth,
+    height: canvasHeight,
+
+    // VENUS & robot coordinates have the origin in the bottom left corner.
+    scaleY: -1,
+    offsetY: canvasHeight,
+  });
+
+  drawRails();
+  drawTooltip();
+
+  // add the layer to the stage
+  stage.add(layer);
+  stage.add(resourceLayer);
+  stage.add(tooltipLayer);
+  tooltipLayer.scaleY(-1);
+  tooltipLayer.offsetY(canvasHeight);
+  updateStatusLabel("disconnected");
+
+  openSocket();
+});
diff --git a/pyhamilton/liquid_handling/errors.py b/pyhamilton/liquid_handling/errors.py
new file mode 100644
index 0000000..e432a92
--- /dev/null
+++ b/pyhamilton/liquid_handling/errors.py
@@ -0,0 +1,2 @@
+class NoTipsException(Exception):
+  pass
diff --git a/pyhamilton/liquid_handling/liquid_classes.py b/pyhamilton/liquid_handling/liquid_classes.py
new file mode 100644
index 0000000..d69675a
--- /dev/null
+++ b/pyhamilton/liquid_handling/liquid_classes.py
@@ -0,0 +1,323 @@
+import enum
+import typing
+
+__all__ = [
+ "HighVolumeFilter_Water_DispenseJet_Empty_with_transport_vol",
+ "HighVolumeFilter_Water_DispenseSurface_Empty",
+ "HighVolumeFilter_Water_DispenseJet_Empty_no_transport_vol",
+ "StandardVolume_Water_DispenseSurface_Part_no_transport_vol",
+ "StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol",
+]
+
+
+class LiquidDevice(enum.Enum):
+  """ Liquid device as defined by Hamilton CO-RE Liquid Editor
+
+  Seems to be the liquid handler head.
+  """
+  # pylint: disable=invalid-name
+
+  CHANNELS_1000uL = enum.auto()
+  CORE_384 = enum.auto()
+  CORE_384_WASH_STATION = enum.auto()
+  CHANNELS_5mL = enum.auto()
+  CORE_96 = enum.auto()
+  CORE_96_WASH_STATION = enum.auto()
+  CR_NEEDLE_WAS_STATION = enum.auto()
+  DC_WASH_STATION = enum.auto()
+  NEEDLE_WASH_STATION_THIRD_GENERATION = enum.auto()
+
+
+class TipType(enum.Enum):
+  """ Tip type as defined by Hamilton CO-RE Liquid Editor
+
+  TODO: there are a few tip types missing here, maybe add those later.
+  """
+  # pylint: disable=invalid-name
+
+  HIGH_VOLUME_TIP_1000uL = 4
+  HIGH_VOLUME_TIP_WITH_FILTER_1000uL = 5
+  NEEDLE_1000uL = 13
+  LOW_VOLUME_TIP_10uL = 2
+  LOW_VOLUME_TIP_WITH_FILTER_10uL = 3
+  NEEDLE_10uL = 11
+  STANDARD_VOLUME_TIP_300uL = 0
+  STANDARD_VOLUME_TIP_WITH_FILTER_300uL = 1
+  TIP_5mL = 25
+
+
+class CorrectionCurvePoint:
+  """ TypedDict for points on a correction curve.
+
+  See `LiquidClass` and `compute_corrected_volume`. """
+
+  target: float
+  correct: float
+
+
+class LiquidClass:
+  """ Wrapper class for liquid classes. """
+
+  def __init__(
+    self,
+    device: typing.List[LiquidDevice],
+    tip_type: TipType,
+    dispense_mode: int,
+    pressure_lld: int,
+    max_height_difference: int,
+    flow_rate: typing.Tuple[int, int],
+    mix_flow_rate: typing.Tuple[int, int],
+    air_transport_volume: typing.Tuple[int, int],
+    blowout_volume: typing.Tuple[int, int],
+    swap_speed: typing.Tuple[int, int],
+    settling_time: typing.Tuple[int, int],
+    over_aspirate_volume: int,
+    clot_retract_height: int,
+    stop_flow_rate: int,
+    stop_back_volume: int,
+    correction_curve: CorrectionCurvePoint
+  ):
+    """ Initialize a new liquid class.
+
+    All tuple arguments are (aspirate, dispense) pairs.
+
+    Args:
+      device: list of supported devices
+      tip_type: supported tip type
+      dispense_mode: 0 = jet partial volume, 1 = jet empty tip (blow out), 2 = surface partial
+        volume 3 = surface empty tip (blow out), 4 = Empty tip at fix position
+      pressure_lld: 0 = low, 1 = medium, 2 = high, 3 = very high
+      max_height_difference: unknown
+      flow_rate: ul/s
+      mix_flow_rate: ul/s
+      air_transport_volume: ul
+      blowout_volume: ul
+      swap_speed: mm/s
+      settling_time: s
+      over_aspirate_volume: ul, aspirate only
+      clot_retract_height: mm, aspirate only
+      stop_flow_rate: ul/s, dispense only
+      stop_back_volume: ul, dispense only
+      correction_curve: series of data points matching target values (keys) to
+        corrected values (values), which are actually used for dispensation/aspiration commands. If
+        length > 0, (0, 0) will automatically be added (possibly overriding).
+    """
+
+    self.device = device
+    self.tip_type = tip_type
+    self.dispense_mode = dispense_mode
+    self.pressure_lld = pressure_lld
+    self.max_height_difference = max_height_difference
+    self.flow_rate = flow_rate
+    self.mix_flow_rate = mix_flow_rate
+    self.air_transport_volume = air_transport_volume
+    self.blowout_volume = blowout_volume
+    self.swap_speed = swap_speed
+    self.settling_time = settling_time
+    self.over_aspirate_volume = over_aspirate_volume
+    self.clot_retract_height = clot_retract_height
+    self.stop_flow_rate = stop_flow_rate
+    self.stop_back_volume = stop_back_volume
+    self.correction_curve = correction_curve
+    if len(correction_curve):
+      self.correction_curve.update({0: 0})
+
+  def compute_corrected_volume(self, target_volume: float) -> float:
+    """ Compute corrected volume using the correction curve.
+
+    Uses the correction curve data point if an exact match is
+    available. If the volume is bigger or smaller than the
+    min/max key, the min/max key will be used. Otherwise, linear
+    interpolation between the two nearest data points is used. If
+    no correction curve available, the initial volume will be returned.
+
+    Args:
+      Target volume that needs to be pipetted.
+
+    Returns:
+      Volume that should actually be pipetted to reach target volume.
+    """
+
+    targets = sorted(self.correction_curve.keys())
+
+    if len(targets) == 0:
+      return target_volume
+
+    if target_volume in self.correction_curve:
+      return self.correction_curve[target_volume]
+
+    # use min non-zero value, so second index (if len(targets)>0,
+    # then 0 was automatically added at initialization).
+    if target_volume < targets[1]: # smaller than min
+      return self.correction_curve[targets[1]]/targets[1] * target_volume
+    if target_volume > targets[-1]: # larger than max
+      return self.correction_curve[targets[-1]]/targets[-1] * target_volume
+
+    # interpolate between two nearest points.
+    for pt, t in zip(targets[:-1], targets[1:]):
+      if pt < target_volume < t:
+        return (self.correction_curve[t]-self.correction_curve[pt])/(t-pt) * \
+               (target_volume - t) + self.correction_curve[t] # (y = slope * (x-x1) + y1)
+
+  @property
+  def aspirate_kwargs(self):
+    """ Return kwargs for aspiration. """
+    return {
+      "transport_air_volume": [self.air_transport_volume[0] * 10],
+      "blow_out_air_volume": [self.blowout_volume[0] * 10],
+      "settling_time": [self.settling_time[0] * 10],
+      "clot_detection_height": [self.clot_retract_height]
+    }
+
+  @property
+  def dispense_kwargs(self):
+    """ Return kwargs for dispensing. """
+    return {
+      "transport_air_volume": [self.air_transport_volume[1] * 10],
+      "blow_out_air_volume": [self.blowout_volume[1] * 10],
+      "settling_time": [self.settling_time[1] * 10],
+      "dispensing_mode": [self.dispense_mode],
+    }
+
+
+#: HighVolumeFilter_Water_DispenseJet_Empty_with_transport_vol
+HighVolumeFilter_Water_DispenseJet_Empty_with_transport_vol = LiquidClass(
+  device=[LiquidDevice.CHANNELS_1000uL],
+  tip_type=TipType.HIGH_VOLUME_TIP_WITH_FILTER_1000uL,
+  dispense_mode=1, # jet empty tip
+  pressure_lld=0,
+  max_height_difference=0,
+  flow_rate=(250, 400),
+  mix_flow_rate=(250, 1),
+  air_transport_volume=(0, 0),
+  blowout_volume=(40, 40),
+  swap_speed=(2, 1),
+  settling_time=(1, 0),
+  over_aspirate_volume=0,
+  clot_retract_height=0,
+  stop_flow_rate=250,
+  stop_back_volume=0,
+  correction_curve={
+    10: 13.3,
+    20: 24.6,
+    50: 57.2,
+    100: 109.6,
+    200: 212.9,
+    500: 521.7,
+    1000: 1034.0
+  }
+)
+
+
+#: HighVolumeFilter_Water_DispenseSurface_Empty
+HighVolumeFilter_Water_DispenseSurface_Empty = LiquidClass(
+  device=[LiquidDevice.CHANNELS_1000uL],
+  tip_type=TipType.HIGH_VOLUME_TIP_WITH_FILTER_1000uL,
+  dispense_mode=3, # surface empty tip
+  pressure_lld=0,
+  max_height_difference=0,
+  flow_rate=(250, 120),
+  mix_flow_rate=(120, 120),
+  air_transport_volume=(5, 5),
+  blowout_volume=(0, 0),
+  swap_speed=(2, 2),
+  settling_time=(1, 0),
+  over_aspirate_volume=5,
+  clot_retract_height=0,
+  stop_flow_rate=5,
+  stop_back_volume=0,
+  correction_curve={
+    10: 12.5,
+    20: 23.9,
+    50: 56.3,
+    100: 108.3,
+    200: 211.0,
+    500: 518.3,
+    1000: 1028.5
+  }
+)
+
+
+#: HighVolumeFilter_Water_DispenseJet_Empty_no_transport_vol
+HighVolumeFilter_Water_DispenseJet_Empty_no_transport_vol = LiquidClass(
+  device=[LiquidDevice.CHANNELS_1000uL],
+  tip_type=TipType.HIGH_VOLUME_TIP_WITH_FILTER_1000uL,
+  dispense_mode=1, # jet empty tip
+  pressure_lld=0,
+  max_height_difference=0,
+  flow_rate=(150, 150),
+  mix_flow_rate=(150, 1),
+  air_transport_volume=(0, 0),
+  blowout_volume=(15, 15),
+  swap_speed=(2, 1),
+  settling_time=(1, 0),
+  over_aspirate_volume=0,
+  clot_retract_height=0,
+  stop_flow_rate=250,
+  stop_back_volume=0,
+  correction_curve={
+    10: 13.3,
+    20: 24.6,
+    50: 57.2,
+    100: 109.6,
+    200: 212.9,
+    500: 521.7,
+    1000: 1034.0
+  }
+)
+
+#: StandardVolume_Water_DispenseSurface_Part_no_transport_vol
+StandardVolume_Water_DispenseSurface_Part_no_transport_vol = LiquidClass(
+  device=[LiquidDevice.CHANNELS_1000uL],
+  tip_type=TipType.STANDARD_VOLUME_TIP_300uL,
+  dispense_mode=2, # surface partial
+  pressure_lld=0,
+  max_height_difference=0,
+  flow_rate=(100, 120),
+  mix_flow_rate=(100, 1),
+  air_transport_volume=(0, 0),
+  blowout_volume=(0, 0),
+  swap_speed=(2, 2),
+  settling_time=(1, 0),
+  over_aspirate_volume=0,
+  clot_retract_height=0,
+  stop_flow_rate=5,
+  stop_back_volume=0,
+  correction_curve={
+    5: 6.5,
+    10: 11.9,
+    20: 23.2,
+    50: 55.1,
+    100: 107.2,
+    200: 211.0,
+    300: 313.5
+  }
+)
+
+#: StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol
+StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol = LiquidClass(
+  device=[LiquidDevice.CHANNELS_1000uL],
+  tip_type=TipType.STANDARD_VOLUME_TIP_300uL,
+  dispense_mode=2, # surface partial
+  pressure_lld=0,
+  max_height_difference=0,
+  flow_rate=(100, 120),
+  mix_flow_rate=(100, 1),
+  air_transport_volume=(0, 0),
+  blowout_volume=(0, 0),
+  swap_speed=(2, 2),
+  settling_time=(1, 0),
+  over_aspirate_volume=0,
+  clot_retract_height=0,
+  stop_flow_rate=5,
+  stop_back_volume=0,
+  correction_curve={
+    5: 6.5,
+    10: 11.9,
+    20: 23.2,
+    50: 55.1,
+    100: 107.2,
+    200: 211.0,
+    300: 313.5
+  }
+)
diff --git a/pyhamilton/liquid_handling/liquid_handler.py b/pyhamilton/liquid_handling/liquid_handler.py
new file mode 100644
index 0000000..960897c
--- /dev/null
+++ b/pyhamilton/liquid_handling/liquid_handler.py
@@ -0,0 +1,1225 @@
+import copy
+import functools
+import inspect
+import json
+import logging
+import typing
+
+import pyhamilton.utils.file_parsing as file_parser
+from pyhamilton import utils
+
+from .backends import LiquidHandlerBackend
+from . import resources
+from .liquid_classes import (
+  LiquidClass,
+  StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol
+)
+from .resources import (
+  Resource,
+  Coordinate,
+  Carrier,
+  Plate,
+  Tips,
+  TipType
+)
+# from .liquid_classes import LiquidClass
+
+logger = logging.getLogger(__name__) # TODO: get from somewhere else?
+
+
+_RAILS_WIDTH = 22.5 # space between rails (mm)
+
+
+class AspirationInfo:
+  """ AspirationInfo is a class that contains information about an aspiration.
+
+  This class is be used by
+  :meth:`pyhamilton.liquid_handling.liquid_handler.LiquidHandler.aspirate` to store information
+  about the aspiration for each individual channel.
+
+  Examples:
+    Directly initialize the class:
+
+    >>> aspiration_info = AspirationInfo('A1', 50)
+    >>> aspiration_info.position
+    'A1'
+    >>> aspiration_info.volume
+    50
+
+    Instantiate an aspiration info object from a tuple:
+
+    >>> AspirationInfo.from_tuple(('A1', 50))
+    AspirationInfo(position='A1', volume=50)
+
+    Instantiate an aspiration info object from a dict:
+
+    >>> AspirationInfo.from_dict({'position': 'A1', 'volume': 50})
+    AspirationInfo(position='A1', volume=50)
+
+    Get the corrected volume, using the default liquid class
+    (:class:`pyhamilton.liquid_handling.liquid_classes.StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol`):
+
+    >>> aspiration_info = AspirationInfo('A1', 100)
+    >>> aspiration_info.get_corrected_volume()
+    107.2
+  """
+
+  def __init__(
+    self,
+    position: str,
+    volume: float,
+    liquid_class: LiquidClass = StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol
+  ):
+    """ Initialize the aspiration info.
+
+    Args:
+      position: The position of the aspiration. Positions are formatted as `<row><column>` where
+        `<row>` is the row string (`A` for row 1, `B` for row 2, etc.) and `<column>` is the column
+        number. For example, `A1` is the top left corner of the resource and `H12` is the bottom
+        right.
+      volume: The volume of the aspiration.
+      liquid_class: The liquid class of the aspiration.
+    """
+
+    self.position = position
+    self.volume = volume
+    self.liquid_class = liquid_class
+
+  @classmethod
+  def from_tuple(cls, tuple_):
+    """ Create aspiration info from a tuple.
+
+    The tuple should either be in the form (position, volume) or (position, volume, liquid_class).
+    In the former case, the liquid class will be set to
+    `StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol`. (TODO: link to liquid class
+    in docs)
+
+    Args:
+      tuple: A tuple in the form (position, volume) or (position, volume, liquid_class)
+
+    Returns:
+      AspirationInfo object.
+
+    Raises:
+      ValueError if the tuple is not in the correct format.
+    """
+
+    if len(tuple_) == 2:
+      position, volume = tuple_
+      return cls(position, volume)
+    elif len(tuple_) == 3:
+      position, volume, liquid_class = tuple_
+      return cls(position, volume, liquid_class)
+    else:
+      raise ValueError("Invalid tuple length")
+
+  @classmethod
+  def from_dict(cls, dict_):
+    """ Create aspiration info from a dictionary.
+
+    The dictionary should either be in the form {"position": position, "volume": volume} or
+    {"position": position, "volume": volume, "liquid_class": liquid_class}. In the former case,
+    the liquid class will be set to
+    `StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol`.
+
+    Args:
+      dict: A dictionary in the form {"position": position, "volume": volume} or
+        {"position": position, "volume": volume, "liquid_class": liquid_class}
+
+    Returns:
+      AspirationInfo object.
+
+    Raises:
+      ValueError: If the dictionary is invalid.
+    """
+
+    if "position" in dict_ and "volume" in dict_:
+      position = dict_["position"]
+      volume = dict_["volume"]
+      return cls(
+        position=position,
+        volume=volume,
+        liquid_class=dict_.get("liquid_class",
+          StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol))
+
+    raise ValueError("Invalid dictionary")
+
+  def __repr__(self):
+    return f"AspirationInfo(position={self.position}, volume={self.volume})"
+
+  def get_corrected_volume(self):
+    """ Get the corrected volume.
+
+    The corrected volume is computed based on various properties of a liquid, as defined by the
+    :class:`pyhamilton.liquid_handling.liquid_classes.LiquidClass` object.
+
+    Returns:
+      The corrected volume.
+    """
+
+    return self.liquid_class.compute_corrected_volume(self.volume)
+
+  def serialize(self):
+    """ Serialize the aspiration info.
+
+    Returns:
+      A dictionary containing the serialized dispense info.
+    """
+
+    return {
+      "position": self.position,
+      "volume": self.volume,
+      "liquid_class": self.liquid_class.__class__.__name__
+    }
+
+
+class DispenseInfo:
+  """ DispenseInfo is a class that contains information about an dispense.
+
+  This class is be used by
+  :meth:`pyhamilton.liquid_handling.liquid_handler.LiquidHandler.aspirate` to store information
+  about the dispense for each individual channel.
+
+  Examples:
+    Directly initialize the class:
+
+    >>> dispense_info = DispenseInfo('A1', 0.5)
+    >>> dispense_info.position
+    'A1'
+    >>> dispense_info.volume
+    0.5
+
+    Instantiate an dispense info object from a tuple:
+
+    >>> DispenseInfo.from_tuple(('A1', 0.5))
+    DispenseInfo(position='A1', volume=0.5)
+
+    Instantiate an dispense info object from a dict:
+
+    >>> DispenseInfo.from_dict({'position': 'A1', 'volume': 0.5})
+    DispenseInfo(position='A1', volume=0.5)
+
+    Get the corrected volume:
+
+    >>> dispense_info = DispenseInfo('A1', 100)
+    >>> dispense_info.get_corrected_volume()
+    107.2
+  """
+
+  def __init__(
+    self,
+    position: str,
+    volume: float,
+    liquid_class: LiquidClass = StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol
+  ):
+    """ Initialize the dispense info.
+
+    Args:
+      position: The position of the dispense. Positions are formatted as `<row><column>` where
+        `<row>` is the row string (`A` for row 1, `B` for row 2, etc.) and `<column>` is the column
+        number. For example, `A1` is the top left corner of the resource and `H12` is the bottom
+        right.
+      volume: The volume of the dispense.
+      liquid_class: The liquid class of the dispense.
+    """
+
+    self.position = position
+    self.volume = volume
+    self.liquid_class = liquid_class
+
+  @classmethod
+  def from_tuple(cls, tuple_):
+    """ Create dispense info from a tuple.
+
+    The tuple should either be in the form (position, volume) or (position, volume, liquid_class).
+    In the former case, the liquid class will be set to
+    `StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol`. (TODO: link to liquid class
+    in docs)
+
+    Args:
+      tuple: A tuple in the form (position, volume) or (position, volume, liquid_class)
+
+    Returns:
+      DispenseInfo object.
+
+    Raises:
+      ValueError if the tuple is not in the correct format.
+    """
+
+    if len(tuple_) == 2:
+      position, volume = tuple_
+      return cls(position, volume)
+    elif len(tuple_) == 3:
+      position, volume, liquid_class = tuple_
+      return cls(position, volume, liquid_class)
+    else:
+      raise ValueError("Invalid tuple length")
+
+  @classmethod
+  def from_dict(cls, dict):
+    """ Create dispense info from a dictionary.
+
+    The dictionary should either be in the form {"position": position, "volume": volume} or
+    {"position": position, "volume": volume, "liquid_class": liquid_class}. In the former case,
+    the liquid class will be set to
+    `StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol`.
+
+    Args:
+      dict: A dictionary in the form {"position": position, "volume": volume} or
+        {"position": position, "volume": volume, "liquid_class": liquid_class}
+
+    Returns:
+      DispenseInfo object.
+
+    Raises:
+      ValueError: If the dictionary is invalid.
+    """
+
+    if "position" in dict and "volume" in dict:
+      position = dict["position"]
+      volume = dict["volume"]
+      return cls(
+        position=position,
+        volume=volume,
+        liquid_class=dict.get("liquid_class",
+          StandardVolumeFilter_Water_DispenseSurface_Part_no_transport_vol))
+
+    raise ValueError("Invalid dictionary")
+
+  def __repr__(self):
+    return f"DispenseInfo(position={self.position}, volume={self.volume})"
+
+  def get_corrected_volume(self):
+    """ Get the corrected volume.
+
+    The corrected volume is computed based on various properties of a liquid, as defined by the
+    :class:`pyhamilton.liquid_handling.liquid_classes.LiquidClass` object.
+
+    Returns:
+      The corrected volume.
+    """
+
+    return self.liquid_class.compute_corrected_volume(self.volume)
+
+  def serialize(self):
+    """ Serialize the dispense info.
+
+    Returns:
+      A dictionary containing the serialized dispense info.
+    """
+
+    return {
+      "position": self.position,
+      "volume": self.volume,
+      "liquid_class": self.liquid_class.__class__.__name__
+    }
+
+
+class LiquidHandler:
+  """
+  Front end for liquid handlers.
+
+  This class is the front end for liquid handlers; it provides a high-level interface for
+  interacting with liquid handlers. In the background, this class uses the low-level backend (
+  defined in `pyhamilton.liquid_handling.backends`) to communicate with the liquid handler.
+
+  This class is responsible for:
+    - Parsing and validating the layout.
+    - Performing liquid handling operations. This includes:
+      - Aspirating from / dispensing liquid to a location.
+      - Transporting liquid from one location to another.
+      - Picking up tips from and dropping tips into a tip box.
+    - Serializing and deserializing the liquid handler deck. Decks are serialized as JSON and can
+      be loaded from a JSON or .lay (legacy) file.
+    - Static analysis of commands. This includes checking the presence of tips on the head, keeping
+      track of the number of tips in the tip box, and checking the volume of liquid in the liquid
+      handler.
+
+  Attributes:
+    setup_finished: Whether the liquid handler has been setup.
+  """
+
+  def __init__(self, backend: LiquidHandlerBackend):
+    """ Initialize a LiquidHandler.
+
+    Args:
+      backend: Backend to use.
+    """
+
+    self.backend = backend
+    self._resources = {}
+    self.setup_finished = False
+
+  def need_setup_finished(func: typing.Callable): # pylint: disable=no-self-argument
+    """ Decorator for methods that require the liquid handler to be set up.
+
+    Checked by verifying `self.setup_finished` is `True`.
+
+    Raises:
+      RuntimeError: If the liquid handler is not set up.
+    """
+
+    @functools.wraps(func)
+    def wrapper(self, *args, **kwargs):
+      if not self.setup_finished:
+        raise RuntimeError("The setup has not finished. See `LiquidHandler.setup`.")
+      func(self, *args, **kwargs) # pylint: disable=not-callable
+    return wrapper
+
+  def setup(self):
+    """ Prepare the robot for use. """
+
+    if self.setup_finished:
+      raise RuntimeError("The setup has already finished. See `LiquidHandler.stop`.")
+
+    self.backend.setup()
+
+    for resource in self._resources.values():
+      self.backend.assigned_resource_callback(resource)
+
+    self.setup_finished = True
+
+  def stop(self):
+    self.backend.stop()
+
+  def __enter__(self):
+    self.setup()
+    return self
+
+  def __exit__(self, *exc):
+    self.stop()
+    return False
+
+  @staticmethod
+  def _x_coordinate_for_rails(rails: int):
+    """ Convert a rail identifier (1-30 for STARLet, max 54 for STAR) to an x coordinate. """
+    return 100.0 + (rails - 1) * _RAILS_WIDTH
+
+  @staticmethod
+  def _rails_for_x_coordinate(x: int):
+    """ Convert an x coordinate to a rail identifier (1-30 for STARLet, max 54 for STAR). """
+    return int((x - 100.0) / _RAILS_WIDTH) + 1
+
+  def assign_resource(
+    self,
+    resource: Resource,
+    rails: typing.Optional[int] = None, # board location, 1..52
+    location: typing.Optional[Coordinate] = None,
+    # y: int, # board location, x..y?
+    replace: bool = False
+  ):
+    """ Assign a new deck resource.
+
+    The identifier will be the Resource.name, which must be unique amongst previously assigned
+    resources.
+
+    Note that some resources, such as tips on a tip carrier or plates on a plate carrier must
+    be assigned directly to the tip or plate carrier respectively. See TipCarrier and PlateCarrier
+    for details.
+
+    Based on the rails argument, the absolute (x, y, z) coordinates will be computed.
+
+    Args:
+      resource: A Resource to assign to this liquid handler.
+      rails: The left most real (inclusive) of the deck resource (between and 1-30 for STARLet,
+             max 54 for STAR.) Either rails or location must be None, but not both.
+      location: The location of the resource relative to the liquid handler. Either rails or
+                location must be None, but not both.
+      replace: Replace the resource with the same name that was previously assigned, if it exists.
+               If a resource is assigned with the same name and replace is False, a ValueError
+               will be raised.
+
+    Raises:
+      ValueError: If a resource is assigned with the same name and replace is `False`.
+    """
+
+    if (rails is not None) == (location is not None):
+      raise ValueError("Rails or location must be None.")
+
+    if rails is not None and not 1 <= rails <= 30:
+      raise ValueError("Rails must be between 1 and 30.")
+
+    # Check if resource exists.
+    if resource.name in self._resources:
+      if replace:
+        # unassign first, so we don't have problems with location checking later.
+        self.unassign_resource(resource.name)
+      else:
+        raise ValueError(f"Resource with name '{resource.name}' already defined.")
+
+    # Set resource location.
+    if rails is not None:
+      resource.location = Coordinate(x=LiquidHandler._x_coordinate_for_rails(rails), y=63, z=100)
+    else:
+      resource.location = location
+
+    if resource.location.x + resource.size_x > LiquidHandler._x_coordinate_for_rails(30):
+      raise ValueError(f"Resource with width {resource.size_x} does not fit at rails {rails}.")
+
+    # Check if there is space for this new resource.
+    for og_resource in self._resources.values():
+      og_x = og_resource.location.x
+
+      # No space if start or end (=x+width) between start and end of current ("og") resource.
+      if og_x <= resource.location.x < og_x + og_resource.size_x or \
+         og_x <= resource.location.x + resource.size_x < og_x + og_resource.size_x:
+        resource.location = None # Revert location.
+        raise ValueError(f"Rails {rails} is already occupied by resource '{og_resource.name}'.")
+
+    # If the resource is a Carrier, add callbacks to self.
+    if isinstance(resource, Carrier):
+      resource.set_check_can_assign_resource_callback(
+        self._check_subresource_can_be_assigned_callback())
+      resource_assigned_callback = self._subresource_assigned_callback(resource)
+      resource.set_resource_assigned_callback(resource_assigned_callback)
+      resource_unassigned_callback = self._subresource_assigned_callback(resource)
+      resource.set_resource_unassigned_callback(resource_unassigned_callback)
+
+    self._resources[resource.name] = resource
+
+    # Only call the backend if the setup is finished.
+    if self.setup_finished:
+      self.backend.assigned_resource_callback(resource)
+
+  def _check_subresource_can_be_assigned_callback(self) -> typing.Optional[str]:
+    """ Returns the error message for the error that would occur if this resource would be assigned,
+    if any. """
+
+    def callback(subresource: Resource):
+      if self.get_resource(subresource.name) is not None:
+        return f"A resource with name '{subresource.name}' already assigned."
+      return None
+    return callback
+
+  def _subresource_assigned_callback(self, resource: Resource):
+    """
+    Returns a callback that can be used to call the `unassinged_resource_callback` and
+    `assigned_resource_callback` of the backend.
+
+    Raises a `ValueError` if a resource with the same name is already assigned.
+    """
+
+    def callback(subresource: Resource):
+      if self.get_resource(resource.name) is not None:
+        # If the resource was already assigned, do a reassign in callbacks. Get resource from self
+        # to update location.
+        resource_ = self.get_resource(resource.name)
+        self.backend.unassigned_resource_callback(resource_.name)
+        self.backend.assigned_resource_callback(resource_)
+    return callback
+
+  def unassign_resource(self, resource: typing.Union[str, Resource]):
+    """ Unassign an assigned resource.
+
+    Args:
+      resource: The resource to unassign.
+
+    Raises:
+      KeyError: If the resource is not currently assigned to this liquid handler.
+    """
+
+    if isinstance(resource, Resource):
+      resource = resource.name
+    del self._resources[resource]
+    self.backend.unassigned_resource_callback(resource)
+
+  def get_resource(self, name: str) -> typing.Optional[Resource]:
+    """ Find a resource in self or contained in a carrier in self.
+
+    Args:
+      name: name of the resource.
+
+    Returns:
+      A deep copy of resource with name `name`, if it exists, else None. Location will be
+      updated to represent the location within the liquid handler.
+    """
+
+    for key, resource in self._resources.items():
+      if key == name:
+        return copy.deepcopy(resource)
+
+      if isinstance(resource, Carrier):
+        if resource.has_resource(name):
+          subresource = copy.deepcopy(resource.get_resource_by_name(name))
+          subresource.location += resource.location
+          # TODO: Why do we need `+ Coordinate(0, resource.location.y, 0)`??? (=63)
+          subresource.location += Coordinate(0, resource.location.y, 0)
+          return subresource
+
+    return None
+
+  def summary(self):
+    """ Prints a string summary of the deck layout.
+
+    Example:
+      Printing a summary of the deck layout:
+
+      >>> lh.summary()
+      Rail     Resource                   Type                Coordinates (mm)
+      ===============================================================================================
+      (1) ├── tip_car                    TIP_CAR_480_A00     (x: 100.000, y: 240.800, z: 164.450)
+          │   ├── tips_01                STF_L               (x: 117.900, y: 240.000, z: 100.000)
+    """
+
+    if len(self._resources) == 0:
+      raise ValueError(
+          "This liquid editor does not have any resources yet. "
+          "Build a layout first by calling `assign_resource()`. "
+          "See the documentation for details. (TODO: link)"
+      )
+
+    # Print header.
+    print(utils.pad_string("Rail", 9) + utils.pad_string("Resource", 27) + \
+          utils.pad_string("Type", 20) + "Coordinates (mm)")
+    print("=" * 95)
+
+    def print_resource(resource):
+      rails = LiquidHandler._rails_for_x_coordinate(resource.location.x)
+      rail_label = utils.pad_string(f"({rails})", 4)
+      print(f"{rail_label} ├── {utils.pad_string(resource.name, 27)}"
+            f"{utils.pad_string(resource.__class__.__name__, 20)}"
+            f"{resource.location}")
+
+      if isinstance(resource, Carrier):
+        for subresource in resource.get_items():
+          if subresource is None:
+            print("     │   ├── <empty>")
+          else:
+            # Get subresource using `self.get_resource` to update it with the new location.
+            subresource = self.get_resource(subresource.name)
+            print(f"     │   ├── {utils.pad_string(subresource.name, 27-4)}"
+                  f"{utils.pad_string(subresource.__class__.__name__, 20)}"
+                  f"{subresource.location}")
+
+    # Sort resources by rails, left to right in reality.
+    sorted_resources = sorted(self._resources.values(), key=lambda r: r.location.x)
+
+    # Print table body.
+    print_resource(sorted_resources[0])
+    for resource in sorted_resources[1:]:
+      print("     │")
+      print_resource(resource)
+
+  def load_from_lay_file(self, fn: str):
+    """ Parse a .lay file (legacy layout definition) and build the layout on this liquid handler.
+
+    Args:
+      fn: Filename of .lay file.
+    """
+
+    c = None
+    with open(fn, "r", encoding="ISO-8859-1") as f:
+      c = f.read()
+
+    # Get class names of all defined resources.
+    resource_classes = [c[0] for c in inspect.getmembers(resources)]
+
+    # Get number of items on deck.
+    num_items = file_parser.find_int("Labware.Cnt", c)
+
+    # Collect all items on deck.
+
+    containers = {}
+    children = {}
+
+    for i in range(1, num_items+1):
+      name = file_parser.find_string(f"Labware.{i}.Id", c)
+
+      # get class name (generated from file name)
+      file_name = file_parser.find_string(f"Labware.{i}.File", c).split("\\")[-1]
+      class_name = None
+      if ".rck" in file_name:
+        class_name = file_name.split(".rck")[0]
+      elif ".tml" in file_name:
+        class_name = file_name.split(".tml")[0]
+
+      if class_name in resource_classes:
+        klass = getattr(resources, class_name)
+        resource = klass(name=name)
+      else:
+        # TODO: replace with real template.
+        # logger.warning(
+          # "Resource with classname %s not found. Please file an issue at "
+          # "https://github.com/pyhamilton/pyhamilton/issues/new?assignees=&"
+          # "labels=&template=bug_report.md&title=Class\%20%s\%20not\%20found", class_name)
+        continue
+
+      # get location props
+      # 'default' template means resource are placed directly on the deck, otherwise it
+      # contains the name of the containing resource.
+      if file_parser.find_string(f"Labware.{i}.Template", c) == "default":
+        x = file_parser.find_float(f"Labware.{i}.TForm.3.X", c)
+        y = file_parser.find_float(f"Labware.{i}.TForm.3.Y", c)
+        z = file_parser.find_float(f"Labware.{i}.ZTrans", c)
+        resource.location = Coordinate(x=x, y=y, z=z)
+        containers[name] = resource
+      else:
+        children[name] = {
+          "container": file_parser.find_string(f"Labware.{i}.Template", c),
+          "site": file_parser.find_int(f"Labware.{i}.SiteId", c),
+          "resource": resource}
+
+    # Assign child resources to their parents.
+    for child in children.values():
+      cont = containers[child["container"]]
+      cont[5 - child["site"]] = child["resource"]
+
+    # Assign all resources to self.
+    for cont in containers.values():
+      self.assign_resource(cont, location=cont.location)
+
+  def save(self, fn: str, indent: typing.Optional[int] = None):
+    """ Save a deck layout to a JSON file.
+
+    Args:
+      fn: File name. Caution: file will be overwritten.
+      indent: Same as `json.dump`'s `indent` argument (for json pretty printing).
+    """
+
+    serialized_resources = []
+
+    for resource in self._resources.values():
+      serialized_resources.append(resource.serialize())
+
+    deck = dict(resources=serialized_resources)
+
+    with open(fn, "w", encoding="utf-8") as f:
+      json.dump(deck, f, indent=indent)
+
+  def load_from_json(self, fn: str):
+    """ Load deck layout serialized in a layout file.
+
+    Args:
+      fn: File name.
+    """
+
+    with open(fn, "r", encoding="utf-8") as f:
+      content = json.load(f)
+    dict_resources = content["resources"]
+
+    # Get class names of all defined resources.
+    resource_classes = [c[0] for c in inspect.getmembers(resources)]
+
+    for resource_dict in dict_resources:
+      klass_type = resource_dict["type"]
+      location = Coordinate.deserialize(resource_dict.pop("location"))
+      if klass_type in resource_classes: # properties pre-defined
+        klass = getattr(resources, resource_dict["type"])
+        resource = klass(name=resource_dict["name"])
+      else: # read properties explicitly
+        args = dict(
+          name=resource_dict["name"],
+          size_x=resource_dict["size_x"],
+          size_y=resource_dict["size_y"],
+          size_z=resource_dict["size_z"]
+        )
+        if "type" in resource_dict:
+          args["type"] = resource_dict["type"]
+        subresource = subresource_klass(**args)
+
+      if "sites" in resource_dict:
+        for subresource_dict in resource_dict["sites"]:
+          if subresource_dict["site"]["resource"] is None:
+            continue
+          subtype = subresource_dict["site"]["resource"]["type"]
+          if subtype in resource_classes: # properties pre-defined
+            subresource_klass = getattr(resources, subtype)
+            subresource = subresource_klass(name=subresource_dict["site"]["resource"]["name"])
+          else: # Custom resources should deserialize the properties they serialized.
+            subresource = subresource_klass(**subresource_dict["site"]["resource"])
+          resource[subresource_dict["site_id"]] = subresource
+
+      self.assign_resource(resource, location=location)
+
+  def load(self, fn: str, file_format: typing.Optional[str] = None):
+    """ Load deck layout serialized in a file, either from a .lay or .json file.
+
+    Args:
+      fn: Filename for serialized model file.
+      format: file format (`json` or `lay`). If None, file format will be inferred from file name.
+    """
+
+    extension = "." + (file_format or fn.split(".")[-1])
+    if extension == ".json":
+      self.load_from_json(fn)
+    elif extension == ".lay":
+      self.load_from_lay_file(fn)
+    else:
+      raise ValueError(f"Unsupported file extension: {extension}")
+
+  def _assert_positions_unique(self, positions: typing.List[str]):
+    """ Returns whether all items in `positions` are unique where they are not `None`.
+
+    Args:
+      positions: List of positions.
+    """
+
+    not_none = [p for p in positions if p is not None]
+    if len(not_none) != len(set(not_none)):
+      raise ValueError("Positions must be unique.")
+
+  @need_setup_finished
+  def pickup_tips(
+    self,
+    resource: typing.Union[str, Tips],
+    channel_1: typing.Optional[str] = None,
+    channel_2: typing.Optional[str] = None,
+    channel_3: typing.Optional[str] = None,
+    channel_4: typing.Optional[str] = None,
+    channel_5: typing.Optional[str] = None,
+    channel_6: typing.Optional[str] = None,
+    channel_7: typing.Optional[str] = None,
+    channel_8: typing.Optional[str] = None,
+    **backend_kwargs
+  ):
+    """ Pick up tips from a resource.
+
+    Exampels:
+      Pick up all tips in the first column.
+      >>> lh.pickup_tips(tips_resource, "A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1")
+
+      Specifying each channel explicitly:
+      >>> lh.pickup_tips(
+      ...   tips_resource,
+      ...   channel_1="A1",
+      ...   channel_2="B1",
+      ...   channel_3="C1",
+      ...   channel_4="D1",
+      ...   channel_5="E1",
+      ...   channel_6="F1",
+      ...   channel_7="G1",
+      ...   channel_8="H1"
+      ... )
+
+      Pick up tips from the diagonal:
+      >>> lh.pickup_tips(tips_resource, "A1", "B2", "C3", "D4", "E5", "F6", "G7", "H8")
+
+    Args:
+      resource: Resource name or resource object.
+      channel_1: The location where the tip will be picked up. If None, this channel will not pick
+        up a tip.
+      channel_2: The location where the tip will be picked up. If None, this channel will not pick
+        up a tip.
+      channel_3: The location where the tip will be picked up. If None, this channel will not pick
+        up a tip.
+      channel_4: The location where the tip will be picked up. If None, this channel will not pick
+        up a tip.
+      channel_5: The location where the tip will be picked up. If None, this channel will not pick
+        up a tip.
+      channel_6: The location where the tip will be picked up. If None, this channel will not pick
+        up a tip.
+      channel_7: The location where the tip will be picked up. If None, this channel will not pick
+        up a tip.
+      channel_8: The location where the tip will be picked up. If None, this channel will not pick
+        up a tip.
+      kwargs: Additional keyword arguments for the backend, optional.
+
+    Raises:
+      RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`.
+
+      ValueError: If no channel will pick up a tip, in other words, if all channels are `None`.
+
+      ValueError: If the positions are not unique.
+    """
+
+    positions = [channel_1, channel_2, channel_3, channel_4,
+                 channel_5, channel_6, channel_7, channel_8]
+
+    self._assert_positions_unique(positions)
+    assert any(position is not None for position in positions), "Must have at least one tip to " + \
+                                                                "pick up."
+
+    # Get resource using `get_resource` to adjust location.
+    if not isinstance(resource, str):
+      if isinstance(resource, Tips):
+        resource = resource.name
+      else:
+        raise ValueError("Resource must be a string or a Tips object.")
+    resource = self.get_resource(resource)
+
+    assert resource is not None, "Resource not found."
+
+    self.backend.pickup_tips(
+      resource,
+      channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+      **backend_kwargs
+    )
+
+  @need_setup_finished
+  def discard_tips(
+    self,
+    resource: typing.Union[str, Tips],
+    channel_1: typing.Optional[str] = None,
+    channel_2: typing.Optional[str] = None,
+    channel_3: typing.Optional[str] = None,
+    channel_4: typing.Optional[str] = None,
+    channel_5: typing.Optional[str] = None,
+    channel_6: typing.Optional[str] = None,
+    channel_7: typing.Optional[str] = None,
+    channel_8: typing.Optional[str] = None,
+    **backend_kwargs
+  ):
+    """ Discard tips to a resource.
+
+    Args:
+      resource: Resource name or resource object.
+      channel_1: The location where the tip will be discarded. If None, this channel will not
+        discard a tip.
+      channel_2: The location where the tip will be discarded. If None, this channel will not
+        discard a tip.
+      channel_3: The location where the tip will be discarded. If None, this channel will not
+        discard a tip.
+      channel_4: The location where the tip will be discarded. If None, this channel will not
+        discard a tip.
+      channel_5: The location where the tip will be discarded. If None, this channel will not
+        discard a tip.
+      channel_6: The location where the tip will be discarded. If None, this channel will not
+        discard a tip.
+      channel_7: The location where the tip will be discarded. If None, this channel will not
+        discard a tip.
+      channel_8: The location where the tip will be discarded. If None, this channel will not
+        discard a tip.
+      kwargs: Additional keyword arguments for the backend, optional.
+
+    Raises:
+      RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`.
+
+      ValueError: If no channel will pick up a tip, in other words, if all channels are `None`.
+
+      ValueError: If the positions are not unique.
+    """
+
+    positions = [channel_1, channel_2, channel_3, channel_4,
+                 channel_5, channel_6, channel_7, channel_8]
+
+    self._assert_positions_unique(positions)
+    assert any(position is not None for position in positions), "Must have at least one tip to " + \
+                                                                "pick up."
+
+    # Get resource using `get_resource` to adjust location.
+    if not isinstance(resource, str):
+      if isinstance(resource, Tips):
+        resource = resource.name
+      else:
+        raise ValueError("Resource must be a string or a Tips object.")
+    resource = self.get_resource(resource)
+
+    assert resource is not None, "Resource not found."
+
+    self.backend.discard_tips(
+      resource,
+      channel_1, channel_2, channel_3, channel_4, channel_5, channel_6, channel_7, channel_8,
+      **backend_kwargs
+    )
+
+  @need_setup_finished
+  def aspirate(
+    self,
+    resource: typing.Union[str, Resource],
+    channel_1: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_2: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_3: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_4: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_5: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_6: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_7: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    channel_8: typing.Optional[typing.Union[tuple, dict, AspirationInfo]] = None,
+    **backend_kwargs
+  ):
+    """Aspirate liquid from the specified channels.
+
+    Examples:
+      Aspirate liquid from the specified channels using a tuple:
+
+      >>> aspirate("plate_01", ('A1', 50), ('B1', 50))
+
+      Aspirate liquid from the specified channels using a dictionary:
+
+      >>> aspirate("plate_02", {'position': 'A1', 'volume': 50}, {'position': 'B1', 'volume': 50})
+
+      Aspirate liquid from the specified channels using an AspirationInfo object:
+
+      >>> aspiration_info_1 = AspirationInfo('A1', 50)
+      >>> aspiration_info_2 = AspirationInfo('B1', 50)
+      >>> aspirate("plate_01", aspiration_info_1, aspiration_info_2)
+
+    Args:
+      resource: Resource name or resource object.
+      channel_1: The aspiration info for channel 1.
+      channel_2: The aspiration info for channel 2.
+      channel_3: The aspiration info for channel 3.
+      channel_4: The aspiration info for channel 4.
+      channel_5: The aspiration info for channel 5.
+      channel_6: The aspiration info for channel 6.
+      channel_7: The aspiration info for channel 7.
+      channel_8: The aspiration info for channel 8.
+      backend_kwargs: Additional keyword arguments for the backend, optional.
+
+    Raises:
+      RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`.
+
+      ValueError: If the resource could not be found. See :meth:`~LiquidHandler.assign_resource`.
+
+      ValueError: If the aspiration info is invalid, in other words, when all channels are `None`.
+
+      ValueError: If all channels are `None`.
+    """
+
+    channels = [channel_1, channel_2, channel_3, channel_4,
+                channel_5, channel_6, channel_7, channel_8]
+
+    # Check that there is at least one channel specified
+    if not any(channel is not None for channel in channels):
+      raise ValueError("No channels specified")
+
+    # Get resource using `get_resource` to adjust location.
+    if isinstance(resource, Plate):
+      resource = resource.name
+    resource = self.get_resource(resource)
+    if not resource:
+      raise ValueError(f"Resource with name {resource} not found.")
+
+    # Convert the channels to `AspirationInfo` objects
+    channels_dict = {}
+    for channel_id, channel in enumerate(channels):
+      if channel is None:
+        channels_dict[f"channel_{channel_id+1}"] = None
+      elif isinstance(channel, tuple):
+        channels_dict[f"channel_{channel_id+1}"] = AspirationInfo.from_tuple(channel)
+      elif isinstance(channel, dict):
+        channels_dict[f"channel_{channel_id+1}"] = AspirationInfo.from_dict(channel)
+      elif isinstance(channel, AspirationInfo):
+        channels_dict[f"channel_{channel_id+1}"] = channel
+      else:
+        raise ValueError(f"Invalid channel type for channel {channel_id+1}")
+
+    self.backend.aspirate(resource, **channels_dict, **backend_kwargs)
+
+  @need_setup_finished
+  def dispense(
+    self,
+    resource: typing.Union[str, Resource],
+    channel_1: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_2: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_3: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_4: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_5: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_6: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_7: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    channel_8: typing.Optional[typing.Union[tuple, dict, DispenseInfo]] = None,
+    **backend_kwargs
+  ):
+    """Dispense liquid from the specified channels.
+
+    Examples:
+      Dispense liquid from the specified channels using a tuple:
+
+      >>> dispense("plate_01", ('A1', 50), ('B1', 50))
+
+      Dispense liquid from the specified channels using a dictionary:
+
+      >>> dispense("plate_02", {'position': 'A1', 'volume': 50}, {'position': 'B1', 'volume': 50})
+
+      Dispense liquid from the specified channels using an DispenseInfo object:
+
+      >>> dispense_info_1 = DispenseInfo('A1', 50)
+      >>> dispense_info_2 = DispenseInfo('B1', 50)
+      >>> dispense("plate_01", dispense_info_1, dispense_info_2)
+
+    Args:
+      resource: Resource name or resource object.
+      channel_1: The dispense info for channel 1.
+      channel_2: The dispense info for channel 2.
+      channel_3: The dispense info for channel 3.
+      channel_4: The dispense info for channel 4.
+      channel_5: The dispense info for channel 5.
+      channel_6: The dispense info for channel 6.
+      channel_7: The dispense info for channel 7.
+      channel_8: The dispense info for channel 8.
+      backend_kwargs: Additional keyword arguments for the backend, optional.
+
+    Raises:
+      RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`.
+
+      ValueError: If the resource could not be found. See :meth:`~LiquidHandler.assign_resource`.
+
+      ValueError: If the dispense info is invalid, in other words, when all channels are `None`.
+
+      ValueError: If all channels are `None`.
+    """
+
+    channels = [channel_1, channel_2, channel_3, channel_4,
+                channel_5, channel_6, channel_7, channel_8]
+
+    # Check that there is at least one channel specified
+    if not any(channel is not None for channel in channels):
+      raise ValueError("No channels specified")
+
+    # Get resource using `get_resource` to adjust location.
+    if isinstance(resource, Plate):
+      resource = resource.name
+    resource = self.get_resource(resource)
+    if not resource:
+      raise ValueError(f"Resource with name {resource} not found.")
+
+    # Convert the channels to `DispenseInfo` objects
+    channels_dict = {}
+    for channel_id, channel in enumerate(channels):
+      if channel is None:
+        channels_dict[f"channel_{channel_id+1}"] = None
+      elif isinstance(channel, tuple):
+        channels_dict[f"channel_{channel_id+1}"] = AspirationInfo.from_tuple(channel)
+      elif isinstance(channel, dict):
+        channels_dict[f"channel_{channel_id+1}"] = AspirationInfo.from_dict(channel)
+      elif isinstance(channel, AspirationInfo):
+        channels_dict[f"channel_{channel_id+1}"] = channel
+      else:
+        raise ValueError(f"Invalid channel type for channel {channel_id+1}")
+
+    self.backend.dispense(resource, **channels_dict, **backend_kwargs)
+
+  def pickup_tips96(self, resource: typing.Union[str, Resource], **backend_kwargs):
+    """ Pick up tips using the CoRe 96 head. This will pick up 96 tips.
+
+    Examples:
+      Pick up tips from an entire 96 tips plate:
+
+      >>> lh.pickup_tips96("plate_01")
+
+      Pick up tips from the left half of a 96 well plate:
+
+      >>> lh.pickup_tips96("plate_01")
+
+    Args:
+      resource: Resource name or resource object.
+      backend_kwargs: Additional keyword arguments for the backend, optional.
+    """
+
+    # Get resource using `get_resource` to adjust location.
+    if isinstance(resource, Tips):
+      resource = resource.name
+    resource = self.get_resource(resource)
+    if not resource:
+      raise ValueError(f"Resource with name {resource} not found.")
+
+    self.backend.pickup_tips96(resource, **backend_kwargs)
+
+  def discard_tips96(self, resource: typing.Union[str, Resource], **backend_kwargs):
+    """ Discard tips using the CoRe 96 head. This will discard 96 tips.
+
+    Examples:
+      Discard tips to an entire 96 tips plate:
+
+      >>> lh.discard_tips96("plate_01")
+
+    Args:
+      resource: Resource name or resource object.
+      backend_kwargs: Additional keyword arguments for the backend, optional.
+    """
+
+    # Get resource using `get_resource` to adjust location.
+    if isinstance(resource, Tips):
+      resource = resource.name
+    resource = self.get_resource(resource)
+    if not resource:
+      raise ValueError(f"Resource with name {resource} not found.")
+
+    self.backend.discard_tips96(resource, **backend_kwargs)
+
+  def aspirate96(
+    self,
+    resource: typing.Union[str, Resource],
+    pattern: typing.Union[typing.List[typing.List[bool]], str],
+    volume: float,
+    **backend_kwargs
+  ):
+    """ Aspirate liquid using the CoR96 head in the locations where pattern is `True`.
+
+    Examples:
+      Aspirate an entire 96 well plate:
+
+      >>> lh.aspirate96("plate_01", "A1:H12", volume=50)
+
+      Aspirate an entire 96 well plate:
+
+      >>> lh.aspirate96("plate_01", [[True]*12]*8, volume=50)
+
+      Aspirate from the left half of a 96 well plate:
+
+      >>> lh.aspirate96("plate_01", "A1:H6", volume=50)
+
+      Aspirate from the left half of a 96 well plate:
+
+      >>> lh.aspirate96("plate_01", [[True]*6+[False]*6]*8], volume=50)
+
+    Args:
+      resource: Resource name or resource object.
+      pattern: Either a list of lists of booleans where inner lists represent rows and outer lists
+        represent columns, or a string representing a range of positions.
+      volume: The volume to aspirate from each well.
+      backend_kwargs: Additional keyword arguments for the backend, optional.
+    """
+
+    # Get resource using `get_resource` to adjust location.
+    if isinstance(resource, Plate):
+      resource = resource.name
+    resource = self.get_resource(resource)
+    if not resource:
+      raise ValueError(f"Resource with name {resource} not found.")
+
+    # Convert the pattern to a list of lists of booleans
+    if isinstance(pattern, str):
+      pattern = utils.string_to_pattern(pattern)
+
+    utils.assert_shape(pattern, (8, 12))
+
+    self.backend.aspirate96(resource, pattern, volume, **backend_kwargs)
+
+  def dispense96(
+    self,
+    resource: typing.Union[str, Resource],
+    pattern: typing.Union[typing.List[typing.List[bool]], str],
+    volume: float,
+    **backend_kwargs
+  ):
+    """ Dispense liquid using the CoR96 head in the locations where pattern is `True`.
+
+    Examples:
+      Dispense an entire 96 well plate:
+
+      >>> dispense96("plate_01", [[True * 12] * 8], volume=50)
+
+      Dispense an entire 96 well plate:
+
+      >>> dispense96("plate_01", "A1:H12", volume=50)
+
+      Dispense from the left half of a 96 well plate:
+
+      >>> dispense96("plate_01", "A1:H6", volume=50)
+
+      Dispense from the left half of a 96 well plate:
+
+      >>> dispense96("plate_01", [[True]*6+[False]*6]*8], volume=50)
+
+    Args:
+      resource: Resource name or resource object.
+      pattern: Either a list of lists of booleans where inner lists represent rows and outer lists
+        represent columns, or a string representing a range of positions.
+      volume: The volume to dispense to each well.
+      backend_kwargs: Additional keyword arguments for the backend, optional.
+    """
+
+    # Get resource using `get_resource` to adjust location.
+    if isinstance(resource, Plate):
+      resource = resource.name
+    resource = self.get_resource(resource)
+    if not resource:
+      raise ValueError(f"Resource with name {resource} not found.")
+
+    # Convert the pattern to a list of lists of booleans
+    if isinstance(pattern, str):
+      pattern = utils.string_to_pattern(pattern)
+
+    utils.assert_shape(pattern, (8, 12))
+
+    self.backend.dispense96(resource, pattern, volume, **backend_kwargs)
diff --git a/pyhamilton/liquid_handling/resources/README b/pyhamilton/liquid_handling/resources/README
new file mode 100644
index 0000000..fd57afd
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/README
@@ -0,0 +1,1 @@
+ATTENTION! All files here, except for files in ./abstract, are automatically generated. See /tools/make_resources.py.
diff --git a/pyhamilton/liquid_handling/resources/__init__.py b/pyhamilton/liquid_handling/resources/__init__.py
new file mode 100644
index 0000000..eafc0df
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/__init__.py
@@ -0,0 +1,3 @@
+from .abstract import *
+from .corning_costar import *
+from .ml_star import *
diff --git a/pyhamilton/liquid_handling/resources/abstract/__init__.py b/pyhamilton/liquid_handling/resources/abstract/__init__.py
new file mode 100644
index 0000000..d98292a
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/abstract/__init__.py
@@ -0,0 +1,6 @@
+from .carrier import Carrier, TipCarrier, PlateCarrier, CarrierSite
+from .coordinate import Coordinate
+from .plate import Plate
+from .resource import Resource
+from .tips import Tips
+from .tip_type import *
diff --git a/pyhamilton/liquid_handling/resources/abstract/carrier.py b/pyhamilton/liquid_handling/resources/abstract/carrier.py
new file mode 100644
index 0000000..4fa88ce
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/abstract/carrier.py
@@ -0,0 +1,252 @@
+from abc import ABCMeta
+import logging
+import typing
+
+from pyhamilton import utils
+
+from .resource import Resource
+from .coordinate import Coordinate
+
+
+logger = logging.getLogger(__name__)
+
+
+class CarrierSite(metaclass=ABCMeta):
+  """ A single site within a carrier. """
+
+  def __init__(
+    self,
+    location: Coordinate,
+    width: float,
+    height: float,
+    resource: typing.Optional[Resource] = None
+  ):
+    """ Initialize a new CarrierSite.
+
+    The location of the site is given by the `location` parameter. The location of the resource
+    will be updated to be the absolute location of the resource.
+
+    Args:
+      location: The location of the site.
+      width: The width of the site.
+      height: The height of the site.
+      resource: The resource that is stored in the site.
+    """
+
+    self.location = location
+    self.width = width
+    self.height = height
+
+    # Update the coordinate to be relative to the carrier.
+    if resource is not None:
+      resource.location += self.location
+    self.resource = resource
+
+  def serialize(self) -> dict:
+    """ Serialize the site to a dict. """
+
+    return {
+      "location": self.location.serialize(),
+      "width": self.width,
+      "height": self.height,
+      "resource": (self.resource.serialize() if self.resource else None)
+    }
+
+  def __repr__(self) -> str:
+    """ Return a string representation of the site. """
+
+    return f"CarrierSite(location={self.location.__repr__()}, width={self.width}, " + \
+           f"height={self.height}, resource={self.resource})"
+
+
+class Carrier(Resource, metaclass=ABCMeta):
+  """ Abstract base resource for carriers.
+
+  It is recommended to always use a resource carrier to store resources, because this ensures the
+  location of the resources can be calculated precisely.
+
+  It is important to use the `__getitem__` and `__setitem__` methods to access the resources,
+  because this ensures that the location of the resources is updated to be within the carrier and
+  that the appropriate callbacks are called.
+
+  Examples:
+    Creating a `TipCarrier` and assigning one set of tips at location 0 (the bottom):
+
+    >>> tip_car = TIP_CAR_480_A00(name='tip carrier')
+    >>> tip_car[0] = STF_L(name='tips_1')
+
+    Getting the tips:
+
+    >>> tip_car[0]
+
+    STF_L(name='tips_1')
+
+    Deleting the tips:
+
+    >>> del tip_car[0]
+
+    Alternative way to delete the tips:
+
+    >>> tip_car[0] = None
+
+  Attributes:
+    capacity: The maximum number of items that can be stored in this carrier.
+  """
+
+  def __init__(
+    self,
+    name: str,
+    size_x: float,
+    size_y: float,
+    size_z: float,
+    sites: typing.List[CarrierSite],
+    category: str = "carrier",
+    resource_assigned_callback: typing.Optional[typing.Callable] = None,
+    resource_unassigned_callback: typing.Optional[typing.Callable] = None,
+    check_can_assign_resource_callback: typing.Optional[typing.Callable] = None,
+  ):
+    super().__init__(name, size_x, size_y, size_z, category=category)
+    self.capacity = len(sites)
+    self._sites = sites
+    self._resource_assigned_callback = resource_assigned_callback
+    self._resource_unassigned_callback = resource_unassigned_callback
+    self._check_can_assign_resource_callback = check_can_assign_resource_callback
+
+  def set_check_can_assign_resource_callback(self, callback: typing.Callable):
+    """ Called when a resource is about to be assigned to the robot.
+
+    This callback will be called before the resource is assigned to the robot. If the callback
+    returns `False`, the resource will not be assigned to the robot and a `ValueError` will be
+    raised. This is useful for checking if the resource can be assigned to the robot. Note that the
+    callback will also be called for all resources that were assigned to this carrier before this
+    callback is assigned.
+
+    Args:
+      resource: The resource that is about to be assigned to the robot.
+    """
+
+    self._check_can_assign_resource_callback = callback
+    for site in self._sites:
+      if site.resource is not None:
+        error = callback(site.resource)
+        if error is not None:
+          raise ValueError(f"A resource with name '{site.resource.name}' cannot be assigned to the "
+                    "liquid handler.")
+
+  def set_resource_assigned_callback(self, callback: typing.Callable):
+    """ Set the callback function that is called when a subresource is assigned to a carrier.
+
+    When subresources have already be assigned, this callback will be called at the end of this
+    method for all of them.
+    """
+
+    self._resource_assigned_callback = callback
+    for site in self._sites:
+      if site.resource is not None:
+        callback(site.resource)
+
+  def set_resource_unassigned_callback(self, callback: typing.Callable):
+    """ Set the callback function that is called when a subresource is unassigned from carrier. """
+    self._resource_unassigned_callback = callback
+
+  def get_items(self) -> typing.List[typing.Optional[Resource]]:
+    """ Get all items, using self.__getitem__ (so that the location is within this carrier). """
+    return [self[k] for k in range(self.capacity)]
+
+  def __getitem__(self, key: int) -> typing.Optional[Resource]:
+    """ Get the key'th item in this carrier.
+
+    Returns:
+      The resource, if it exists at that location, where the location is updated to be within this
+        carrier.
+    """
+
+    utils.assert_clamp(key, 0, self.capacity - 1, "key", KeyError)
+    return self._sites[key].resource
+
+  def __setitem__(self, key: int, subresource: typing.Optional[Resource]):
+    utils.assert_clamp(key, 0, self.capacity - 1, "key", KeyError)
+
+    if subresource is None: # `self[k] = None` is equal to `del self[k]`
+      del self[key]
+      return
+
+    # Warn if overriding a site.
+    if self._sites[key].resource is not None:
+      logger.warning("Overriding resource %s with %s.", self._sites[key].resource, subresource)
+    # Check if item with name is not set yet.
+    if subresource.name in [s.name if s is not None else None for s in self.get_items()]:
+      raise ValueError(f"Subresource with name {subresource.name} already set.")
+
+    # Update the location of the resource to be relative to the carrier.
+    subresource.location += self._sites[key].location
+
+    if self._check_can_assign_resource_callback is not None:
+      error = self._check_can_assign_resource_callback(subresource)
+      if error is not None:
+        raise ValueError(f"A resource with name '{subresource.name}' cannot be assigned to the "
+                          "liquid handler.")
+
+    self._sites[key].resource = subresource
+
+    if self._resource_assigned_callback is not None:
+      self._resource_assigned_callback(subresource)
+
+  def __delitem__(self, key: int):
+    utils.assert_clamp(key, 0, self.capacity - 1, "key", KeyError)
+
+    resource = self._sites[key].resource
+    self._sites[key].resource = None
+
+    if self._resource_unassigned_callback is not None:
+      self._resource_unassigned_callback(resource)
+
+  def has_resource(self, resource_name: Resource) -> bool:
+    """ Check if the given resource is stored in this carrier. """
+    resource_names = [r.name for r in self.get_items() if r is not None]
+    return resource_name in resource_names
+
+  def get_resource_by_name(self, name: str) -> typing.Optional[Resource]:
+    """ Get the resource with the given name. """
+    for site in self._sites:
+      if site.resource is not None and site.resource.name == name:
+        return site.resource
+    return None
+
+  def serialize(self):
+    return dict(
+      **super().serialize(),
+      sites=[
+        dict(
+          site_id=site_id,
+          site=site.serialize()
+        ) for site_id, site in enumerate(self._sites)]
+    )
+
+
+class PlateCarrier(Carrier, metaclass=ABCMeta):
+  """ Abstract base class for plate carriers. """
+
+  def __init__(
+    self,
+    name: str,
+    size_x: float,
+    size_y: float,
+    size_z: float,
+    sites: typing.List[Coordinate]
+  ):
+    super().__init__(name, size_x, size_y, size_z, sites, category="plate_carrier")
+
+
+class TipCarrier(Carrier, metaclass=ABCMeta):
+  """ Abstract base class for tip carriers. """
+
+  def __init__(
+    self,
+    name: str,
+    size_x: float,
+    size_y: float,
+    size_z: float,
+    sites: typing.List[Coordinate]
+  ):
+    super().__init__(name, size_x, size_y, size_z, sites, category="tip_carrier")
diff --git a/pyhamilton/liquid_handling/resources/abstract/coordinate.py b/pyhamilton/liquid_handling/resources/abstract/coordinate.py
new file mode 100644
index 0000000..52706c4
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/abstract/coordinate.py
@@ -0,0 +1,41 @@
+
+
+class Coordinate:
+  """ Represents coordinates, often relative to either a liquid handler or container """
+
+  def __init__(self, x, y, z):
+    self.x = x
+    self.y = y
+    self.z = z
+
+  def __add__(self, other):
+    return Coordinate(
+      x=(self.x or 0) + (other.x or 0),
+      y=(self.y or 0) + (other.y or 0),
+      z=(self.z or 0) + (other.z or 0)
+    )
+
+  def __sub__(self, other):
+    return Coordinate(
+      x=(self.x or 0) - (other.x or 0),
+      y=(self.y or 0) - (other.y or 0),
+      z=(self.z or 0) - (other.z or 0)
+    )
+
+  def __eq__(self, other):
+    return self.x == other.x and self.y == other.y and self.z == other.z
+
+  def __str__(self):
+    if self.x is not None and self.y is not None and self.z is not None:
+      return f"({self.x:07.3f}, {self.y:07.3f}, {self.z:07.3f})"
+    return "(None, None, None)"
+
+  def __repr__(self):
+    return f"Coordinate({self.x}, {self.y}, {self.z})"
+
+  def serialize(self) -> dict:
+    return dict(x=self.x, y=self.y, z=self.z)
+
+  @staticmethod
+  def deserialize(d: dict):
+    return Coordinate(x=d["x"], y=d["y"], z=d["z"])
diff --git a/pyhamilton/liquid_handling/resources/abstract/plate.py b/pyhamilton/liquid_handling/resources/abstract/plate.py
new file mode 100644
index 0000000..bf93cec
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/abstract/plate.py
@@ -0,0 +1,34 @@
+""" Abstract base class for Plate resources. """
+
+from abc import ABCMeta
+
+from .resource import Resource, Coordinate
+
+
+class Plate(Resource, metaclass=ABCMeta):
+  """ Abstract base class for Plate resources. """
+
+  def __init__(
+    self,
+    name: str,
+    size_x: float,
+    size_y: float,
+    size_z: float,
+    dx: float,
+    dy: float,
+    dz: float,
+    location: Coordinate = Coordinate(None, None, None)
+  ):
+    super().__init__(name, size_x, size_y, size_z, location=location + Coordinate(dx, dy, dz),
+                     category="plate")
+    self.dx = dx
+    self.dy = dy
+    self.dz = dz
+
+  def serialize(self):
+    return dict(
+      **super().serialize(),
+      dx=self.dx,
+      dy=self.dy,
+      dz=self.dz
+    )
diff --git a/pyhamilton/liquid_handling/resources/abstract/resource.py b/pyhamilton/liquid_handling/resources/abstract/resource.py
new file mode 100644
index 0000000..ad624ce
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/abstract/resource.py
@@ -0,0 +1,45 @@
+from abc import ABCMeta, abstractmethod
+
+from .coordinate import Coordinate
+
+
+class Resource(object, metaclass=ABCMeta):
+  """ Abstract base class for deck resources.
+
+  Args:
+    name: The name of the resource.
+    size_x: The size of the resource in the x-direction.
+    size_y: The size of the resource in the y-direction.
+    size_z: The size of the resource in the z-direction.
+    location: The location of the resource.
+    category: The category of the resource, e.g. `tips`, `plate_carrier`, etc.
+  """
+
+  @abstractmethod
+  def __init__(
+    self,
+    name: str,
+    size_x: float,
+    size_y: float,
+    size_z: float,
+    location: Coordinate = Coordinate(None, None, None),
+    category: str = None
+  ):
+    self.name = name
+    self.size_x = size_x
+    self.size_y = size_y
+    self.size_z = size_z
+    self.location = location
+    self.category = category
+
+  def serialize(self):
+    """ Serialize this resource. """
+    return dict(
+      name=self.name,
+      type=self.__class__.__name__,
+      size_x=self.size_x,
+      size_y=self.size_y,
+      size_z=self.size_z,
+      location=self.location.serialize(),
+      category=self.category or "unknown"
+    )
diff --git a/pyhamilton/liquid_handling/resources/abstract/tip_type.py b/pyhamilton/liquid_handling/resources/abstract/tip_type.py
new file mode 100644
index 0000000..55cace7
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/abstract/tip_type.py
@@ -0,0 +1,73 @@
+from abc import ABCMeta
+
+
+TIP_TYPE_UNDEFINED = 0
+TIP_TYPE_LOW_VOLUME = 1
+TIP_TYPE_STANDARD_VOLUME = 2
+TIP_TYPE_HIGH_VOLUME = 3
+TIP_TYPE_CORE_384 = 4
+TIP_TYPE_XL_CHANNEL = 5
+
+
+class TipType(object, metaclass=ABCMeta):
+  """ TipType class
+
+  Attributes:
+    has_filter: whether the tip type has a filter
+    total_tip_length: total length of the tip, in in mm
+    maximal_volume: maximal volume of the tip, in ul
+    tip_type_id: id of the tip type. 0 = undefined, 1 = low volue, 2 = standard volume,
+      3 = high volume, 4 = CoRe 384 tip, 5 = XL channel tip
+    pick_up_method: pick up method of the tip type. 0 = out of rack, 1 = out of wash liquid
+    filter_length: length of the filter, in 0.1mm, used in TT command. Tip lengths are calculated as
+      follows: total tip length-fitting depth
+  """
+
+  def __init__(
+    self,
+    has_filter: bool,
+    total_tip_length: float,
+    maximal_volume: float,
+    tip_type_id: int,
+    pick_up_method: int
+  ):
+    self.has_filter = has_filter
+    self.total_tip_length = total_tip_length
+    self.maximal_volume = maximal_volume
+    self.tip_type_id = tip_type_id
+    self.pick_up_method = pick_up_method
+
+    fitting_depth = {
+      1: 8,
+      2: 8,
+      3: 8,
+      4: 7.55,
+      5: 10
+    }[tip_type_id]
+    self.tip_length = total_tip_length - fitting_depth
+
+  def __eq__(self, other):
+    return (
+      self.has_filter == other.has_filter and
+      self.total_tip_length == other.total_tip_length and
+      self.maximal_volume == other.maximal_volume and
+      self.tip_type_id == other.tip_type_id and
+      self.pick_up_method == other.pick_up_method
+    )
+
+  def __hash__(self):
+    return hash(self.__repr__())
+
+  def __repr__(self):
+    return (f"TipType(has_filter={self.has_filter}, total_tip_length={self.total_tip_length}, "
+            f"maximal_volume={self.maximal_volume}, tip_type_id={self.tip_type_id}, "
+            f"pick_up_method={self.pick_up_method})")
+
+  def serialize(self):
+    return {
+      "has_filter": self.has_filter,
+      "total_tip_length": self.total_tip_length,
+      "maximal_volume": self.maximal_volume,
+      "tip_type_id": self.tip_type_id,
+      "pick_up_method": self.pick_up_method
+    }
diff --git a/pyhamilton/liquid_handling/resources/abstract/tips.py b/pyhamilton/liquid_handling/resources/abstract/tips.py
new file mode 100644
index 0000000..b1d2dce
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/abstract/tips.py
@@ -0,0 +1,37 @@
+""" Abstract base class for Tips resources. """
+
+from abc import ABCMeta
+
+from .resource import Resource, Coordinate
+
+
+class Tips(Resource, metaclass=ABCMeta):
+  """ Abstract base class for Tips resources. """
+
+  def __init__(
+    self,
+    name: str,
+    size_x: float,
+    size_y: float,
+    size_z: float,
+    tip_type: str,
+    dx: float,
+    dy: float,
+    dz: float,
+    location: Coordinate = Coordinate(None, None, None)
+  ):
+    super().__init__(name, size_x, size_y, size_z, location=location + Coordinate(dx, dy, dz),
+                     category="tips")
+    self.tip_type = tip_type
+    self.dx = dx
+    self.dy = dy
+    self.dz = dz
+
+  def serialize(self):
+    return dict(
+      **super().serialize(),
+      tip_type=self.tip_type.serialize(),
+      dx=self.dx,
+      dy=self.dy,
+      dz=self.dz
+    )
diff --git a/pyhamilton/liquid_handling/resources/corning_costar/__init__.py b/pyhamilton/liquid_handling/resources/corning_costar/__init__.py
new file mode 100644
index 0000000..15ff2bd
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/corning_costar/__init__.py
@@ -0,0 +1,1 @@
+from .plates import *
diff --git a/pyhamilton/liquid_handling/resources/corning_costar/plates.py b/pyhamilton/liquid_handling/resources/corning_costar/plates.py
new file mode 100644
index 0000000..87415db
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/corning_costar/plates.py
@@ -0,0 +1,977 @@
+""" Corning Costar plates """
+
+# pylint: skip-file
+
+from pyhamilton.liquid_handling.resources.abstract import Plate
+
+
+class Cos_96_DW_1mL_P(Plate):
+  """ Cos_96_DW_1mL_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=42.0,
+      dx=11.5,
+      dy=14.0,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*33.1831
+
+
+class Cos_384_Sq_Rd(Plate):
+  """ Cos_384_Sq_Rd """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=11.75,
+      dy=9.25,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*10.0800
+
+
+class Cos_96_PCR_P(Plate):
+  """ Cos_96_PCR_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=23.5,
+      dx=11.5,
+      dy=14.0,
+      dz=0.5
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*23.8237
+
+
+class Cos_1536_10ul_P(Plate):
+  """ Cos_1536_10ul_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=10.25,
+      dx=8.125,
+      dy=10.625,
+      dz=0.5
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*3.6100
+
+
+class Cos_96_ProtCryst_L(Plate):
+  """ Cos_96_ProtCryst_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=20.0,
+      dx=11.7,
+      dy=11.5,
+      dz=10.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*7.5477
+
+
+class Cos_1536_10ul(Plate):
+  """ Cos_1536_10ul """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=10.25,
+      dx=10.625,
+      dy=8.125,
+      dz=0.5
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*3.6100
+
+
+class Cos_384_DW_P(Plate):
+  """ Cos_384_DW_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=28.0,
+      dx=9.25,
+      dy=11.75,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*10.0800
+
+
+class Cos_96_Rd_P(Plate):
+  """ Cos_96_Rd_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=20.0,
+      dx=11.5,
+      dy=14.0,
+      dz=0.75
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_96_UV(Plate):
+  """ Cos_96_UV """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.3,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_96_Fl_L(Plate):
+  """ Cos_96_Fl_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.24,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.2808
+
+
+class Cos_96_EZWash_P(Plate):
+  """ Cos_96_EZWash_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=14.5,
+      dx=11.5,
+      dy=14.0,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*37.3928
+
+
+class Cos_96_DW_500ul(Plate):
+  """ Cos_96_DW_500ul """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=27.5,
+      dx=14.0,
+      dy=11.5,
+      dz=2.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_384_DW(Plate):
+  """ Cos_384_DW """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=28.0,
+      dx=11.75,
+      dy=9.25,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*10.0800
+
+
+class Cos_96_SpecOps(Plate):
+  """ Cos_96_SpecOps """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.3,
+      dx=14.0,
+      dy=11.5,
+      dz=0.1
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_384_Sq_Rd_L(Plate):
+  """ Cos_384_Sq_Rd_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=11.75,
+      dy=9.25,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*10.0800
+
+
+class Cos_96_DW_2mL_P(Plate):
+  """ Cos_96_DW_2mL_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=43.5,
+      dx=11.5,
+      dy=14.0,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*64.0000
+
+
+class Cos_96_Vb_P(Plate):
+  """ Cos_96_Vb_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=14.24,
+      dx=11.5,
+      dy=14.0,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*36.9605
+
+
+class Cos_96_DW_500ul_P(Plate):
+  """ Cos_96_DW_500ul_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=27.5,
+      dx=11.5,
+      dy=14.0,
+      dz=2.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_384_PCR_P(Plate):
+  """ Cos_384_PCR_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=16.0,
+      dx=9.25,
+      dy=11.75,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*2.8510
+
+
+class Cos_96_Rd(Plate):
+  """ Cos_96_Rd """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=14.0,
+      dy=11.5,
+      dz=0.75
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_96_SpecOps_P(Plate):
+  """ Cos_96_SpecOps_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=14.3,
+      dx=11.5,
+      dy=14.0,
+      dz=0.1
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_96_Filter_P(Plate):
+  """ Cos_96_Filter_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=14.5,
+      dx=11.5,
+      dy=14.0,
+      dz=2.1
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_96_ProtCryst(Plate):
+  """ Cos_96_ProtCryst """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=20.0,
+      dx=11.7,
+      dy=11.5,
+      dz=10.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*7.5477
+
+
+class Cos_384_Sq_L(Plate):
+  """ Cos_384_Sq_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.24,
+      dx=11.75,
+      dy=9.25,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*12.2500
+
+
+class Cos_96_UV_P(Plate):
+  """ Cos_96_UV_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=14.3,
+      dx=11.5,
+      dy=14.0,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_96_HalfArea_P(Plate):
+  """ Cos_96_HalfArea_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=14.5,
+      dx=11.5,
+      dy=14.0,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*17.7369
+
+
+class Cos_96_DW_2mL_L(Plate):
+  """ Cos_96_DW_2mL_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=43.5,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*64.0000
+
+
+class Cos_384_Sq_Rd_P(Plate):
+  """ Cos_384_Sq_Rd_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=14.5,
+      dx=9.25,
+      dy=11.75,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*10.0800
+
+
+class Cos_96_EZWash(Plate):
+  """ Cos_96_EZWash """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*37.3928
+
+
+class Cos_96_FL(Plate):
+  """ Cos_96_FL """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.24,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.2808
+
+
+class Cos_384_Sq(Plate):
+  """ Cos_384_Sq """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.24,
+      dx=11.75,
+      dy=9.25,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*12.2500
+
+
+class Cos_96_HalfArea_L(Plate):
+  """ Cos_96_HalfArea_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*17.7369
+
+
+class Cos_96_UV_L(Plate):
+  """ Cos_96_UV_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.3,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_384_Sq_P(Plate):
+  """ Cos_384_Sq_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=14.24,
+      dx=9.25,
+      dy=11.75,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*12.2500
+
+
+class Cos_96_DW_1mL(Plate):
+  """ Cos_96_DW_1mL """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=42.0,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*33.1831
+
+
+class Cos_96_HalfArea(Plate):
+  """ Cos_96_HalfArea """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*17.7369
+
+
+class Cos_96_Filter_L(Plate):
+  """ Cos_96_Filter_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=14.0,
+      dy=11.5,
+      dz=2.1
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_96_SpecOps_L(Plate):
+  """ Cos_96_SpecOps_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.3,
+      dx=14.0,
+      dy=11.5,
+      dz=0.1
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_384_PCR_L(Plate):
+  """ Cos_384_PCR_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=16.0,
+      dx=11.75,
+      dy=9.25,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*2.8510
+
+
+class Cos_96_DW_500ul_L(Plate):
+  """ Cos_96_DW_500ul_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=27.5,
+      dx=14.0,
+      dy=11.5,
+      dz=2.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_96_Vb_L(Plate):
+  """ Cos_96_Vb_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.24,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*36.9605
+
+
+class Cos_96_Filter(Plate):
+  """ Cos_96_Filter """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=14.0,
+      dy=11.5,
+      dz=2.1
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_96_ProtCryst_P(Plate):
+  """ Cos_96_ProtCryst_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=20.0,
+      dx=11.5,
+      dy=11.7,
+      dz=10.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*7.5477
+
+
+class Cos_1536_10ul_L(Plate):
+  """ Cos_1536_10ul_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=10.25,
+      dx=10.625,
+      dy=8.125,
+      dz=0.5
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*3.6100
+
+
+class Cos_96_Vb(Plate):
+  """ Cos_96_Vb """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.24,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*36.9605
+
+
+class Cos_96_PCR(Plate):
+  """ Cos_96_PCR """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=22.5,
+      dx=14.0,
+      dy=11.5,
+      dz=0.5
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*23.8237
+
+
+class Cos_96_PCR_L(Plate):
+  """ Cos_96_PCR_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=23.5,
+      dx=14.0,
+      dy=11.5,
+      dz=0.5
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*23.8237
+
+
+class Cos_96_DW_1mL_L(Plate):
+  """ Cos_96_DW_1mL_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=42.0,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*33.1831
+
+
+class Cos_96_EZWash_L(Plate):
+  """ Cos_96_EZWash_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*37.3928
+
+
+class Cos_96_Fl_P(Plate):
+  """ Cos_96_Fl_P """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=86.0,
+      size_y=127.0,
+      size_z=14.24,
+      dx=11.5,
+      dy=14.0,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.2808
+
+
+class Cos_96_DW_2mL(Plate):
+  """ Cos_96_DW_2mL """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=43.5,
+      dx=14.0,
+      dy=11.5,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*64.0000
+
+
+class Cos_96_Rd_L(Plate):
+  """ Cos_96_Rd_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=14.5,
+      dx=14.0,
+      dy=11.5,
+      dz=0.75
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*34.7486
+
+
+class Cos_384_PCR(Plate):
+  """ Cos_384_PCR """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=16.0,
+      dx=11.75,
+      dy=9.25,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*2.8510
+
+
+class Cos_384_DW_L(Plate):
+  """ Cos_384_DW_L """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=127.0,
+      size_y=86.0,
+      size_z=28.0,
+      dx=11.75,
+      dy=9.25,
+      dz=1.0
+    )
+
+  def compute_volume_from_height(self, h):
+    return h*10.0800
diff --git a/pyhamilton/liquid_handling/resources/ml_star/__init__.py b/pyhamilton/liquid_handling/resources/ml_star/__init__.py
new file mode 100644
index 0000000..8b62136
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/ml_star/__init__.py
@@ -0,0 +1,4 @@
+from .plate_carriers import *
+from .tip_carriers import *
+from .tips import *
+from .tip_types import *
diff --git a/pyhamilton/liquid_handling/resources/ml_star/plate_carriers.py b/pyhamilton/liquid_handling/resources/ml_star/plate_carriers.py
new file mode 100644
index 0000000..54194b8
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/ml_star/plate_carriers.py
@@ -0,0 +1,334 @@
+""" ML Star plate carriers """
+
+# pylint: skip-file
+
+from pyhamilton.liquid_handling.resources.abstract import PlateCarrier, Coordinate, CarrierSite
+
+
+class PLT_CAR_L5FLEX_MD(PlateCarrier):
+  """ Plate carrier with 5 adjustable (height) positions for MTP """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=157.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(15.25, 8.5, 115.8), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 104.5, 115.8), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 200.5, 115.8), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 296.5, 115.8), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 392.5, 115.8), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_P3AC_A00(PlateCarrier):
+  """ Carrier for 3 96 Deep Well Plates (portrait) """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(43.85, 37.5, 86.15), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(43.85, 183.5, 86.15), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(43.85, 329.5, 86.15), width=86.0, height=127.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_P3AC_A01(PlateCarrier):
+  """ Carrier for 3 96 Deep Well Plates (portrait) """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(44.1, 37.5, 85.9), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(44.1, 183.5, 85.9), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(44.1, 329.5, 85.9), width=86.0, height=127.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5FLEX_AC(PlateCarrier):
+  """ Plate carrier with 5 adjustable (height) portrait positions for archive plates """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=157.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(15.25, 8.5, 89.1), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 104.5, 89.1), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 200.5, 89.1), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 296.5, 89.1), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 392.5, 89.1), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5FLEX_MD_A00(PlateCarrier):
+  """ Plate carrier with 5 adjustable (height) positions for MTP """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=157.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(15.25, 8.5, 115.8), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 104.5, 115.8), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 200.5, 115.8), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 296.5, 115.8), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 392.5, 115.8), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L4HD(PlateCarrier):
+  """  """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(4.1, 36.1, 118.25), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.1, 146.1, 118.25), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.1, 256.1, 118.25), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.1, 366.1, 118.25), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_P3HD(PlateCarrier):
+  """  """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(43.9, 27.05, 117.65), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(43.9, 173.05, 117.65), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(43.9, 319.05, 117.65), width=86.0, height=127.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5AC_A00(PlateCarrier):
+  """ Carrier for 5 deep well 96 Well PCR Plates """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(4.0, 8.5, 86.15), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 104.5, 86.15), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 200.5, 86.15), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 296.5, 86.15), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 392.5, 86.15), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5MD_A00(PlateCarrier):
+  """ Carrier for 5 96/384-Well Plates """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(4.0, 8.5, 111.75), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 104.5, 111.75), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 200.5, 111.75), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 296.5, 111.75), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 392.5, 111.75), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5MD(PlateCarrier):
+  """  """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(4.0, 8.5, 111.75), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 104.5, 111.75), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 200.5, 111.75), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 296.5, 111.75), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 392.5, 111.75), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5AC(PlateCarrier):
+  """  """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(4.0, 8.5, 86.15), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 104.5, 86.15), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 200.5, 86.15), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 296.5, 86.15), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 392.5, 86.15), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5FLEX_AC_A00(PlateCarrier):
+  """ Plate carrier with 5 adjustable (height) portrait positions for archive plates """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=157.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(15.25, 8.5, 89.1), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 104.5, 89.1), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 200.5, 89.1), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 296.5, 89.1), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(15.25, 392.5, 89.1), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5PCR(PlateCarrier):
+  """  """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(4.0, 8.5, 107.5), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 104.5, 107.5), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 200.5, 107.5), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 296.5, 107.5), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(4.0, 392.5, 107.5), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_P3MD(PlateCarrier):
+  """  """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(44.1, 37.5, 111.5), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(44.1, 183.5, 111.5), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(44.1, 329.5, 111.5), width=86.0, height=127.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5PCR_A00(PlateCarrier):
+  """ Plate carrier for 5 PCR landscape plates [revision 00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(5.0, 9.5, 109.2), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(5.0, 105.5, 109.2), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(5.0, 201.5, 109.2), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(5.0, 297.5, 109.2), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(5.0, 393.5, 109.2), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_L5PCR_A01(PlateCarrier):
+  """ Plate carrier for 5 PCR landscape plates [revision 01] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(5.0, 9.5, 109.2), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(5.0, 105.5, 109.2), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(5.0, 201.5, 109.2), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(5.0, 297.5, 109.2), width=127.0, height=86.0, resource=None),
+        CarrierSite(location=Coordinate(5.0, 393.5, 109.2), width=127.0, height=86.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_P3MD_A01(PlateCarrier):
+  """ Carrier for 3 96/384-Well Plates (Portrait) """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(44.1, 37.5, 111.5), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(44.1, 183.5, 111.5), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(44.1, 329.5, 111.5), width=86.0, height=127.0, resource=None)
+      ]
+    )
+
+
+class PLT_CAR_P3MD_A00(PlateCarrier):
+  """ Carrier for 3 96/384-Well Plates (Portrait) """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(44.1, 37.5, 111.5), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(44.1, 183.5, 111.5), width=86.0, height=127.0, resource=None),
+        CarrierSite(location=Coordinate(44.1, 329.5, 111.5), width=86.0, height=127.0, resource=None)
+      ]
+    )
diff --git a/pyhamilton/liquid_handling/resources/ml_star/tip_carriers.py b/pyhamilton/liquid_handling/resources/ml_star/tip_carriers.py
new file mode 100644
index 0000000..eb486cd
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/ml_star/tip_carriers.py
@@ -0,0 +1,1266 @@
+""" ML Star tip carriers """
+
+# pylint: skip-file
+
+from pyhamilton.liquid_handling.resources.abstract import TipCarrier, Coordinate, CarrierSite
+
+
+class TIP_CAR_288_TIP_50ul_C00(TipCarrier):
+  """ Tip carrier with 3 50ul tip racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_HTF_B00(TipCarrier):
+  """ Tip carrier with 3 high volume tip with filter racks portrait  [revision B00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=112.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(17.1, 36.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 182.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 328.25, 115.15), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_120BC_4mlTF_A00(TipCarrier):
+  """ Tip carrier with 5 4ml tip with filter racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_NTR_A00(TipCarrier):
+  """ Carrier with 5 nestable tip rack positions """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 29.0), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 29.0), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 29.0), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 29.0), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 29.0), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_ST_B00(TipCarrier):
+  """ Tip carrier with 3 standard volume tip racks portrait  [revision B00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=112.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(17.1, 36.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 182.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 328.25, 115.15), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384_TIP_50ul_A00(TipCarrier):
+  """ Tip carrier with 4 50ul tip racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384BC_HT_A00(TipCarrier):
+  """ Tip carrier with 4 high volume tip racks for 12/16 channel instruments """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384BC_HTF_A00(TipCarrier):
+  """ Tip carrier with 4 high volume tip with filter racks for 12/16 channel instruments """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_LT_A00(TipCarrier):
+  """ Tip carrier with 5 low volume tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_ST_A00(TipCarrier):
+  """ Tip carrier with 5 standard volume tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_HTF_A00(TipCarrier):
+  """ Tip carrier with 5 high volume tip with filter racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384_LTF_A00(TipCarrier):
+  """ Tip carrier with 4 low volume tip with filter racks for 12/16 channel instruments, no barcode identification """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384BC_TIP_50ulF_A00(TipCarrier):
+  """ Tip carrier with 4 50ul tip with filter racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_PiercingTips_A00(TipCarrier):
+  """ Tip carrier with 5 Piercing Tips 250ul tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_PiercingTip150ulFilter_A00(TipCarrier):
+  """ Tip carrier with 5 Piercing Tips 150ul tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480_HTF_A00(TipCarrier):
+  """ Tip carrier with 5 high volume tip with filter racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384_HT_A00(TipCarrier):
+  """ Tip carrier with 4 high volume tip racks for 12/16 channel instruments, no barcode identification """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_LT_B00(TipCarrier):
+  """ Tip carrier with 3 low volume tip racks portrait  [revision B00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=112.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(17.1, 36.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 182.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 328.25, 115.15), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_SlimTips300ulFilter_A00(TipCarrier):
+  """ Tip carrier with 5 Slim Tips 300ul with filters tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_96BC_4mlTF_A00(TipCarrier):
+  """ Carrier for 4 4ml with filter tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_ST_A00(TipCarrier):
+  """ Tip carrier with 3 standard volume tip racks portrait  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(26.3, 36.3, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 182.213, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 328.213, 114.95), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480_ST_A00(TipCarrier):
+  """ Tip carrier with 5 standard volume tip racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_HTF_A00(TipCarrier):
+  """ Tip carrier with 3 high volume tip with filter racks portrait  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(26.3, 36.3, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 182.213, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 328.213, 114.95), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384_A00(TipCarrier):
+  """ Carrier for 4 Racks with 96 Tips landscape [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_HTF_C00(TipCarrier):
+  """ Tip carrier with 3 high volume tip with filter racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384_STF_A00(TipCarrier):
+  """ Tip carrier with 4 standard volume tip with filter racks for 12/16 channel instruments, no barcode identification  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_96BC_5mlT_A00(TipCarrier):
+  """ Carrier for 4 5ml tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_ST_C00(TipCarrier):
+  """ Tip carrier with 3 standard volume tip racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_TIP_50ulF_C00(TipCarrier):
+  """ Tip carrier with 3 50ul tip with filter racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_LT_C00(TipCarrier):
+  """ Tip carrier with 3 low volume tip racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480_LT_A00(TipCarrier):
+  """ Tip carrier with 5 low volume tip racks landscape, no barcode identification """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_72_5mlT_C00(TipCarrier):
+  """ Tip carrier with 3 5ml tip racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_LT_A00(TipCarrier):
+  """ Tip carrier with 3 low volume tip racks portrait  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(26.3, 36.3, 114.9), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 182.213, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 328.213, 114.95), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_LTF_A00(TipCarrier):
+  """ Tip carrier with 5 low volume tip with filter racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384BC_A00(TipCarrier):
+  """ Tip carrier with 4 empty tip rack positions landscape, with Barcode Identification  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_STF_C00(TipCarrier):
+  """ Tip carrier with 3 standard volume tip with filter racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480_LTF_A00(TipCarrier):
+  """ Tip carrier with 5 low volume tip with filter racks landscape, no barcode identification """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_STF_A00(TipCarrier):
+  """ Tip carrier with 3 standard volume tip with filter racks portrait  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(26.3, 36.3, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 182.213, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 328.213, 114.95), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_LTF_B00(TipCarrier):
+  """ Tip carrier with 3 low volume tip with filter racks portrait  [revision B00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=112.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(17.1, 36.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 182.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 328.25, 115.15), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480_HT_A00(TipCarrier):
+  """ Tip carrier with 5 high volume tip racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480_TIP_50ul_A00(TipCarrier):
+  """ Tip carrier with 5 50ul tip racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_B00(TipCarrier):
+  """ Carrier for 3 Racks with 96 Tips portrait [revision B00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=112.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(17.1, 36.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 182.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 328.25, 115.15), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384_TIP_50ulF_A00(TipCarrier):
+  """ Tip carrier with 4 50ul tip with filter racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_HT_A00(TipCarrier):
+  """ Tip carrier with 3 high volume tip racks portrait  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(26.3, 36.3, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 182.213, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 328.213, 114.95), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480_TIP_50ulF_A00(TipCarrier):
+  """ Tip carrier with 5 50ul tip with filter racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_HT_C00(TipCarrier):
+  """ Tip carrier with 3 high volume tip racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384BC_LTF_A00(TipCarrier):
+  """ Tip carrier with 4 low volume tip with filter racks for 12/16 channel instruments """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384BC_STF_A00(TipCarrier):
+  """ Tip carrier with 4 standard volume tip with filter racks for 12/16 channel instruments """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384BC_LT_A00(TipCarrier):
+  """ Tip carrier with 4 low volume tip racks for 12/16 channel instruments """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_HT_A00(TipCarrier):
+  """ Tip carrier with 5 high volume tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_STF_B00(TipCarrier):
+  """ Tip carrier with 3 standard volume tip with filter racks portrait  [revision B00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=112.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(17.1, 36.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 182.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 328.25, 115.15), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384BC_TIP_50ul_A00(TipCarrier):
+  """ Tip carrier with 4 50ul tip racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_72_4mlTF_C00(TipCarrier):
+  """ Tip carrier with 3 4ml tip with filter racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_A00(TipCarrier):
+  """ Tip carrier with 5 tip rack positions landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_TIP_50ul_A00(TipCarrier):
+  """ Tip carrier with 5 50ul tip racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384_ST_A00(TipCarrier):
+  """ Tip carrier with 4 standard volume tip racks for 12/16 channel instruments, no barcode identification  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480(TipCarrier):
+  """ Carrier for 5 Racks with 96 Tips landscape [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_TIP_50ulF_A00(TipCarrier):
+  """ Tip carrier with 5 50ul tip with filter racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480_STF_A00(TipCarrier):
+  """ Tip carrier with 5 standard volume tip with filter racks landscape  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_HT_B00(TipCarrier):
+  """ Tip carrier with 3 high volume tip racks portrait  [revision B00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=112.5,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(17.1, 36.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 182.25, 115.15), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(17.1, 328.25, 115.15), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_A00(TipCarrier):
+  """ Carrier for 3 Racks with 96 Tips portrait  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(26.3, 36.3, 114.9), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 182.213, 114.9), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 328.213, 114.9), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384_LT_A00(TipCarrier):
+  """ Tip carrier with 4 low volume tip racks for 12/16 channel instruments, no barcode identification """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480_A00(TipCarrier):
+  """ Carrier for 5 Racks with 96 Tips landscape [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_LTF_A00(TipCarrier):
+  """ Tip carrier with 3 low volume tip with filter racks portrait  [revision A00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(26.3, 36.3, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 182.213, 114.95), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(26.3, 328.213, 114.95), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_120BC_5mlT_A00(TipCarrier):
+  """ Tip carrier with 5 5ml tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_LTF_C00(TipCarrier):
+  """ Tip carrier with 3 low volume tip with filter racks portrait  [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_STF_A00(TipCarrier):
+  """ Tip carrier with 5 standard volume tip with filter racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_480BC_SlimTips_A00(TipCarrier):
+  """ Tip carrier with 5 Slim Tips 300ul tip racks landscape """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.2, 10.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 106.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 202.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 298.0, 114.95), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.2, 394.0, 114.95), width=122.4, height=82.6, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_288_C00(TipCarrier):
+  """ Carrier for 3 Racks with 96 Tips portrait [revision C00] """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=90.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(3.7, 36.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 182.3, 114.7), width=82.6, height=122.4, resource=None),
+        CarrierSite(location=Coordinate(3.7, 328.3, 114.7), width=82.6, height=122.4, resource=None)
+      ]
+    )
+
+
+class TIP_CAR_384BC_ST_A00(TipCarrier):
+  """ Tip carrier with 4 standard volume tip with filter racks for 12/16 channel instruments """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=135.0,
+      size_y=497.0,
+      size_z=130.0,
+      sites=[
+        CarrierSite(location=Coordinate(6.3, 78.2, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 163.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 248.1, 114.8), width=122.4, height=82.6, resource=None),
+        CarrierSite(location=Coordinate(6.3, 333.1, 114.8), width=122.4, height=82.6, resource=None)
+      ]
+    )
diff --git a/pyhamilton/liquid_handling/resources/ml_star/tip_types.py b/pyhamilton/liquid_handling/resources/ml_star/tip_types.py
new file mode 100644
index 0000000..55d3750
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/ml_star/tip_types.py
@@ -0,0 +1,99 @@
+""" ML Star tip types
+
+The tip length is probably defined by the LSHt field in .ctr files. The values here are
+obtained by matching ids in the liquid editor with the log files (tt parameter matches).
+See the TT command.
+"""
+
+from pyhamilton.liquid_handling.resources.abstract import (
+  TipType,
+  TIP_TYPE_LOW_VOLUME,
+  TIP_TYPE_STANDARD_VOLUME,
+  TIP_TYPE_HIGH_VOLUME,
+  TIP_TYPE_XL_CHANNEL
+)
+
+
+__all__ = [
+  "standard_volume_tip_no_filter",
+  "standard_volume_tip_with_filter",
+  "low_volume_tip_no_filter",
+  "low_volume_tip_with_filter",
+  "high_volume_tip_no_filter",
+  "high_volume_tip_with_filter",
+  "four_ml_tip_with_filter",
+  "five_ml_tip_with_filter"
+]
+
+
+#: Standard volume tip without a filter (`tt00` in venus)
+standard_volume_tip_no_filter = TipType(
+  has_filter=False,
+  total_tip_length=59.9, # 60 in the ctr file, but 59.9 in the log file (519-80)/10
+  maximal_volume=400,
+  tip_type_id=TIP_TYPE_STANDARD_VOLUME,
+  pick_up_method=0
+)
+
+#: Low volume tip without a filter (`tt01` in venus)
+standard_volume_tip_with_filter = TipType(
+  has_filter=True,
+  total_tip_length=59.9, # 60 in the ctr file, but 59.9 in the log file (519-80)/10
+  maximal_volume=360,
+  tip_type_id=TIP_TYPE_STANDARD_VOLUME,
+  pick_up_method=0
+)
+
+#: Standard volume tip with a filter (`tt02` in venus)
+low_volume_tip_no_filter = TipType(
+  has_filter=False,
+  total_tip_length=29.9,
+  maximal_volume=15,
+  tip_type_id=TIP_TYPE_LOW_VOLUME,
+  pick_up_method=0
+)
+
+#: Low volume tip with a filter  (`tt03` in venus)
+low_volume_tip_with_filter = TipType(
+  has_filter=True,
+  total_tip_length=29.9,
+  maximal_volume=10,
+  tip_type_id=TIP_TYPE_LOW_VOLUME,
+  pick_up_method=0
+)
+
+#: High volume tip without a filter (`tt04` in venus)
+high_volume_tip_no_filter = TipType(
+  has_filter=False,
+  total_tip_length=59.9,
+  maximal_volume=1000,
+  tip_type_id=TIP_TYPE_HIGH_VOLUME,
+  pick_up_method=0
+)
+
+#: High volume tip with a filter (`tt05` in venus)
+high_volume_tip_with_filter = TipType(
+  has_filter=True,
+  total_tip_length=95.1, # 95 in the ctr file, but 95.1 in the log file (871-80)/10
+  maximal_volume=1250,
+  tip_type_id=TIP_TYPE_HIGH_VOLUME,
+  pick_up_method=0
+)
+
+#: 4mL tip with a filter (`tt29` in venus)
+four_ml_tip_with_filter = TipType(
+  has_filter=True,
+  total_tip_length=116,
+  maximal_volume=4367,
+  tip_type_id=TIP_TYPE_XL_CHANNEL,
+  pick_up_method=0
+)
+
+#: 5mL tip with a filter (`tt25` in venus)
+five_ml_tip_with_filter = TipType(
+  has_filter=True,
+  total_tip_length=116,
+  maximal_volume=5420,
+  tip_type_id=TIP_TYPE_XL_CHANNEL,
+  pick_up_method=0
+)
diff --git a/pyhamilton/liquid_handling/resources/ml_star/tips.py b/pyhamilton/liquid_handling/resources/ml_star/tips.py
new file mode 100644
index 0000000..d0c45d3
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/ml_star/tips.py
@@ -0,0 +1,143 @@
+""" ML Star tips """
+
+# pylint: skip-file
+
+from pyhamilton.liquid_handling.resources.abstract import Tips
+from .tip_types import (
+  low_volume_tip_no_filter,
+  low_volume_tip_with_filter,
+  standard_volume_tip_no_filter,
+  standard_volume_tip_with_filter,
+  high_volume_tip_no_filter,
+  high_volume_tip_with_filter,
+  four_ml_tip_with_filter,
+  five_ml_tip_with_filter
+)
+
+
+class FourmlTF_L(Tips):
+  """ Tip Rack 24x 4ml Tip with Filter landscape oriented """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=122.4,
+      size_y=82.6,
+      size_z=7.0,
+      tip_type=four_ml_tip_with_filter,
+      dx=16.3,
+      dy=14.2,
+      dz=-93.2,
+    )
+
+
+class LT_L(Tips):
+  """ Rack with 96 10ul Low Volume Tip """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=122.4,
+      size_y=82.6,
+      size_z=20.0,
+      tip_type=low_volume_tip_no_filter,
+      dx=11.7,
+      dy=9.8,
+      dz=-22.5,
+    )
+
+
+class HTF_L(Tips):
+  """ Rack with 96 1000ul High Volume Tip with filter """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=122.4,
+      size_y=82.6,
+      size_z=20.0,
+      tip_type=high_volume_tip_with_filter,
+      dx=11.7,
+      dy=9.8,
+      dz=-83.5,
+    )
+
+
+class HT_L(Tips):
+  """ Rack with 96 1000ul High Volume Tip """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=122.4,
+      size_y=82.6,
+      size_z=20.0,
+      tip_type=high_volume_tip_no_filter,
+      dx=11.7,
+      dy=9.8,
+      dz=-83.5,
+    )
+
+
+class LTF_L(Tips):
+  """ Rack with 96 10ul Low Volume Tip with filter """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=122.4,
+      size_y=82.6,
+      size_z=20.0,
+      tip_type=low_volume_tip_with_filter,
+      dx=11.7,
+      dy=9.8,
+      dz=-22.5,
+    )
+
+
+class FivemlT_L(Tips):
+  """ Tip Rack 24x 5ml Tip landscape oriented """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=122.4,
+      size_y=82.6,
+      size_z=7.0,
+      tip_type=five_ml_tip_with_filter,
+      dx=16.3,
+      dy=14.2,
+      dz=-93.2,
+    )
+
+
+class STF_L(Tips):
+  """ Rack with 96 300ul Standard Volume Tip with filter """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=122.4,
+      size_y=82.6,
+      size_z=20.0,
+      tip_type=standard_volume_tip_with_filter,
+      dx=11.7,
+      dy=9.8,
+      dz=-50.5,
+    )
+
+
+class ST_L(Tips):
+  """ Rack with 96 300ul Standard Volume Tip """
+
+  def __init__(self, name: str):
+    super().__init__(
+      name=name,
+      size_x=122.4,
+      size_y=82.6,
+      size_z=20.0,
+      tip_type=standard_volume_tip_no_filter,
+      dx=11.7,
+      dy=9.8,
+      dz=-50.5,
+    )
diff --git a/pyhamilton/oemerr.py b/pyhamilton/oemerr.py
deleted file mode 100644
index 106b199..0000000
--- a/pyhamilton/oemerr.py
+++ /dev/null
@@ -1,427 +0,0 @@
-"""`pyhamilton`-specific exception definitions.
-"""
-class HamiltonError(Exception):
-    """
-    Exceptions raised in package pyhamilton
-    """
-    pass
-
-###########################################
-### BEGIN HAMILTON DECK RESOURCE ERRORS ###
-###########################################
-
-class HamiltonDeckResourceError(HamiltonError):
-    """
-    Error with any deck object in interface with robot.
-    """
-    pass
-
-class ResourceUnavailableError(HamiltonDeckResourceError):
-    """
-    Layout manager found deck resource type not present or all of this type assigned
-    """
-    pass
-
-#######################################
-### BEGIN HAMILTON INTERFACE ERRORS ###
-#######################################
-
-class HamiltonInterfaceError(HamiltonError):
-    """
-    Error in any phase of communication with robot.
-    """
-    pass
-
-class HamiltonTimeoutError(HamiltonInterfaceError):
-    """
-    An asynchronous request to the Hamilton robot timed out.
-    """
-    pass
-
-class InvalidErrCodeError(HamiltonInterfaceError):
-    """
-    Error code returned from instrument not known.
-    """
-    pass
-
-class HamiltonReturnParseError(HamiltonInterfaceError):
-    """
-    Return string from instrument was malformed.
-    """
-    pass
-
-########################################################
-### BEGIN HAMILTON CODED STEP ERRORS, CODE MAP BELOW ###
-########################################################
-
-class HamiltonStepError(HamiltonError):
-    """
-    Errors in steps executed by VENUS software coded in the Hamilton error specification.
-    """
-    pass
-
-class HamiltonSyntaxError(HamiltonStepError):
-    """
-    There is a wrong set of parameters or parameter ranges.
-    """
-    pass
- 
-class HardwareError(HamiltonStepError):
-    """
-    Steps lost on one or more hardware components, or component not initialized or not functioning.
-    """
-    pass
- 
-class NotExecutedError(HamiltonStepError):
-    """
-    There was an error in previous part command.
-    """
-    pass
-
-class ClotError(HamiltonStepError):
-    """
-    Blood clot detected.
-    """
-    pass
-
-class BarcodeError(HamiltonStepError):
-    """
-    Barcode could not be read or is missing.
-    """
-    pass
-
-class InsufficientLiquidError(HamiltonStepError):
-    """
-    Not enough liquid available.
-    """
-    pass
-
-class TipPresentError(HamiltonStepError):
-    """
-    A tip has already been picked up.
-    """
-    pass
-
-class NoTipError(HamiltonStepError):
-    """
-    Tip is missing or not picked up.
-    """
-    pass
-
-class NoCarrierError(HamiltonStepError):
-    """
-    No carrier present for loading.
-    """
-    pass
-
-class ExecutionError(HamiltonStepError):
-    """
-    A step or a part of a step could not be processed.
-    """
-    pass
-
-class PressureLLDError(HamiltonStepError):
-    """
-    A dispense with pressure liquid level detection is not allowed.
-    """
-    pass
-
-class CalibrateError(HamiltonStepError):
-    """
-    No capacitive signal detected during carrier calibration procedure.
-    """
-    pass
-
-class UnloadError(HamiltonStepError):
-    """
-    Not possible to unload the carrier due to occupied loading tray position.
-    """
-    pass
-
-class PressureLLDError(HamiltonStepError):
-    """
-    Pressure liquid level detection in a consecutive aspiration is not allowed.
-    """
-    pass
-
-class ParameterError(HamiltonStepError):
-    """
-    Dispense in jet mode with pressure liquid level detection is not allowed.
-    """
-    pass
-
-class CoverOpenError(HamiltonStepError):
-    """
-    Cover not closed or can not be locked.
-    """
-    pass
-
-class ImproperAspirationOrDispenseError(HamiltonStepError):
-    """
-    The pressure-based aspiration / dispensation control reported an error ( not enough liquid ).
-    """
-    pass
-
-class WashLiquidError(HamiltonStepError):
-    """
-    Waste full or no more wash liquid available.
-    """
-    pass
-
-class TemperatureError(HamiltonStepError):
-    """
-    Incubator temperature out of range.
-    """
-    pass
-
-class TADMOvershotError(HamiltonStepError):
-    """
-    Overshot of limits during aspirate or dispense.
-
-    Note:
-
-    On aspirate this error is returned as main error 17.
-
-    On dispense this error is returned as main error 4.
-    """
-    pass
-
-class LabwareError(HamiltonStepError):
-    """
-    Labware not available.
-    """
-    pass
-
-class LabwareGrippedError(HamiltonStepError):
-    """
-    Labware already gripped.
-    """
-    pass
-
-class LabwareLostError(HamiltonStepError):
-    """
-    Labware lost during transport.
-    """
-    pass
-
-class IllegalTargetPlatePositionError(HamiltonStepError):
-    """
-    Cannot place plate, plate was gripped in a wrong direction.
-    """
-    pass
-
-class IllegalInterventionError(HamiltonStepError):
-    """
-    Cover was opened or a carrier was removed manually.
-    """
-    pass
-
-class TADMUndershotError(HamiltonStepError):
-    """
-    Undershot of limits during aspirate or dispense.
-
-    Note:
-
-    On aspirate this error is returned as main error 4.
-
-    On dispense this error is returned as main error 17.
-    """
-    pass
-
-class PositionError(HamiltonStepError):
-    """
-    The position is out of range.
-    """
-    pass
-
-class UnexpectedcLLDError(HamiltonStepError):
-    """
-    The cLLD detected a liquid level above start height of liquid level search.
-    """
-    pass
-
-class AreaAlreadyOccupiedError(HamiltonStepError):
-    """
-    Instrument region already reserved.
-    """
-    pass
-
-class ImpossibleToOccupyAreaError(HamiltonStepError):
-    """
-    A region on the instrument cannot be reserved.
-    """
-    pass
-
-class AntiDropControlError(HamiltonStepError):
-    """
-    Anti drop controlling out of tolerance.
-    """
-    pass
-
-class DecapperError(HamiltonStepError):
-    """
-    Decapper lock error while screw / unscrew a cap by twister channels.
-    """
-    pass
-
-class DecapperHandlingError(HamiltonStepError):
-    """
-    Decapper station error while lock / unlock a cap.
-    """
-    pass
-
-class SlaveError(HamiltonStepError):
-    """
-    Slave error.
-    """
-    pass
-
-class WrongCarrierError(HamiltonStepError):
-    """
-    Wrong carrier barcode detected.
-    """
-    pass
-
-class NoCarrierBarcodeError(HamiltonStepError):
-    """
-    Carrier barcode could not be read or is missing.
-    """
-    pass
-
-class LiquidLevelError(HamiltonStepError):
-    """
-    Liquid surface not detected.
-
-    This error is created from main / slave error 06/70, 06/73 and 06/87.
-    """
-    pass
-
-class NotDetectedError(HamiltonStepError):
-    """
-    Carrier not detected at deck end position.
-    """
-    pass
-
-class NotAspiratedError(HamiltonStepError):
-    """
-    Dispense volume exceeds the aspirated volume.
-
-    This error is created from main / slave error 02/54.
-    """
-    pass
-
-class ImproperDispensationError(HamiltonStepError):
-    """
-    The dispensed volume is out of tolerance (may only occur for Nano Pipettor Dispense steps). 
-
-    This error is created from main / slave error 02/52 and 02/54.
-    """
-    pass
-
-class NoLabwareError(HamiltonStepError):
-    """
-    The labware to be loaded was not detected by autoload module.
-
-    Note:
-
-    May only occur on a Reload Carrier step if the labware property 'MlStarCarPosAreRecognizable' is set to 1.
-    """
-    pass
-
-class UnexpectedLabwareError(HamiltonStepError):
-    """
-    The labware contains unexpected barcode ( may only occur on a Reload Carrier step ).
-    """
-    pass
-
-class WrongLabwareError(HamiltonStepError):
-    """
-    The labware to be reloaded contains wrong barcode ( may only occur on a Reload Carrier step ).
-    """
-    pass
-
-class BarcodeMaskError(HamiltonStepError):
-    """
-    The barcode read doesn't match the barcode mask defined.
-    """
-    pass
-
-class BarcodeNotUniqueError(HamiltonStepError):
-    """
-    The barcode read is not unique. Previously loaded labware with same barcode was loaded without unique barcode check.
-    """
-    pass
-
-class BarcodeAlreadyUsedError(HamiltonStepError):
-    """
-    The barcode read is already loaded as unique barcode ( it's not possible to load the same barcode twice ).
-    """
-    pass
-
-class KitLotExpiredError(HamiltonStepError):
-    """
-    Kit Lot expired.
-    """
-    pass
-
-class DelimiterError(HamiltonStepError):
-    """
-    Barcode contains character which is used as delimiter in result string.
-    """
-    pass
-
-
-HAMILTON_ERROR_MAP = { 
-    1: HamiltonSyntaxError,
-    2: HardwareError,
-    3: NotExecutedError,
-    4: ClotError,
-    5: BarcodeError,
-    6: InsufficientLiquidError,
-    7: TipPresentError,
-    8: NoTipError,
-    9: NoCarrierError,
-    10: ExecutionError,
-    11: PressureLLDError,
-    12: CalibrateError,
-    13: UnloadError,
-    14: PressureLLDError,
-    15: ParameterError,
-    16: CoverOpenError,
-    17: ImproperAspirationOrDispenseError,
-    18: WashLiquidError,
-    19: TemperatureError,
-    20: TADMOvershotError,
-    21: LabwareError,
-    22: LabwareGrippedError,
-    23: LabwareLostError,
-    24: IllegalTargetPlatePositionError,
-    25: IllegalInterventionError,
-    26: TADMUndershotError,
-    27: PositionError,
-    28: UnexpectedcLLDError,
-    29: AreaAlreadyOccupiedError,
-    30: ImpossibleToOccupyAreaError,
-    31: AntiDropControlError,
-    32: DecapperError,
-    33: DecapperHandlingError,
-    99: SlaveError,
-    100: WrongCarrierError,
-    101: NoCarrierBarcodeError,
-    102: LiquidLevelError,
-    103: NotDetectedError,
-    104: NotAspiratedError,
-    105: ImproperDispensationError,
-    106: NoLabwareError,
-    107: UnexpectedLabwareError,
-    108: WrongLabwareError,
-    109: BarcodeMaskError,
-    110: BarcodeNotUniqueError,
-    111: BarcodeAlreadyUsedError,
-    112: KitLotExpiredError,
-    113: DelimiterError
-}
-"""
-Maps integer error codes from Hamilton step return data to the appropriate `pyhamilton` errors
-"""
-
diff --git a/pyhamilton/utils/__init__.py b/pyhamilton/utils/__init__.py
new file mode 100644
index 0000000..443068b
--- /dev/null
+++ b/pyhamilton/utils/__init__.py
@@ -0,0 +1,13 @@
+from .list import (
+  assert_shape,
+)
+from .numbers import (
+  assert_clamp
+)
+from .positions import (
+  string_to_position,
+  string_to_pattern
+)
+from .string import (
+  pad_string
+)
diff --git a/pyhamilton/utils/file_parsing.py b/pyhamilton/utils/file_parsing.py
new file mode 100644
index 0000000..06311ae
--- /dev/null
+++ b/pyhamilton/utils/file_parsing.py
@@ -0,0 +1,31 @@
+""" Utilities for parsing Hamilton files (.lay, .tml, .ctr, .rck).
+
+All are based on the seemingly arbitrary use of ascii escape characters.
+"""
+
+def find_int(key, c):
+  for i in range(16*4):
+    for j in range(16*4):
+      try:
+        return int(c.split(key + chr(i))[1].split(chr(j))[0])
+      except (IndexError, ValueError):
+        continue
+
+
+def find_float(key, c):
+  for i in range(16*4):
+    for j in range(16*4):
+      try:
+        return float(c.split(key + chr(i))[1].split(chr(j))[0])
+      except (IndexError, ValueError):
+        continue
+
+
+def find_string(key, c):
+  finds = []
+  for i in range(32):
+    try:
+      finds.append(c.split(key)[1][1:].split(chr(i))[0])
+    except (IndexError, ValueError):
+      continue
+  return min(finds, key=len)
diff --git a/pyhamilton/utils/list.py b/pyhamilton/utils/list.py
new file mode 100644
index 0000000..de051cf
--- /dev/null
+++ b/pyhamilton/utils/list.py
@@ -0,0 +1,18 @@
+""" Utilities for working with lists. """
+
+import typing
+
+
+def assert_shape(list_: list, shape: typing.Tuple[int]):
+  """Assert that a list has the correct shape.
+
+  Args:
+    list_: The list to check.
+    shape: The expected shape.
+  """
+
+  if len(list_) != shape[0]:
+    raise ValueError(f"List has incorrect shape: {list_}")
+  for row in list_:
+    if len(row) != shape[1]:
+      raise ValueError(f"List has incorrect shape: {list_}")
diff --git a/pyhamilton/utils/numbers.py b/pyhamilton/utils/numbers.py
new file mode 100644
index 0000000..88e424d
--- /dev/null
+++ b/pyhamilton/utils/numbers.py
@@ -0,0 +1,8 @@
+""" Utilities for working with numbers (int, float) """
+
+def assert_clamp(v, min_, max_, name, error=AssertionError):
+  if type(v) is not list:
+    v = [v]
+  for w in v:
+    if not min_ <= w <= max_:
+      raise error(f"{name} must be between {min_} and {max_}, but is {w}")
diff --git a/pyhamilton/utils/positions.py b/pyhamilton/utils/positions.py
new file mode 100644
index 0000000..0932214
--- /dev/null
+++ b/pyhamilton/utils/positions.py
@@ -0,0 +1,75 @@
+""" Utilities for working with positions and position strings.
+
+These follow the Hamilton VENUS style, which is like MS Excel, but transposed. So `B1` is the cell
+below the top left (`A1`) and `A2` is the cell to the right of `A1`.
+"""
+
+import itertools
+import typing
+
+
+def string_to_position(position_string: str) -> typing.Tuple[int, int]:
+  """Convert a string to a list of patterns.
+
+  Positions are formatted as `<row><column>` where `<row>` is the row string (`A` for row 1, `B`
+  for row 2, etc.) and `<column>` is the column number. For example, `A1` is the top left corner
+  of the resource and `H12` is the bottom right. This method returns the index for such a
+  position.
+
+  Examples:
+    >>> _string_to_pattern("A1")
+    (0, 0)
+
+    >>> _string_to_pattern("A3")
+    (0, 2)
+
+    >>> _string_to_pattern("C1")
+    (2, 0)
+
+  Args:
+    position_string: The string to convert.
+
+  Returns:
+    A list of patterns.
+  """
+
+  row = ord(position_string[0]) - 65
+  column = int(position_string[1:]) - 1
+  return (row, column)
+
+
+def string_to_pattern(position_range_string: str) -> typing.List[typing.List[bool]]:
+  """ Convert a position string to a pattern.
+
+  Args:
+    position_string: The position string.
+
+  Returns:
+    A list of lists of booleans.
+
+  Examples:
+    Convert `"A1:A3"` to a pattern.
+
+    >>> _string_range_to_pattern("A1:C3")
+    [[True, True, True, False, False, ...], [True, True, True, False, False...],
+      [True, True, True, False, False...], ...]
+
+    Convert `"A1:A3"` to a pattern.
+
+    >>> _string_range_to_pattern("A1:A3")
+    [[True, True, True, False, False, ...], [False, False, ...], ...]
+
+    Convert `"A1:C1"` to a pattern.
+
+    >>> _string_range_to_pattern("A1:C1")
+    [[True, False, ...], [True, False, ...], [True, False, ...], [False, False, ...], ...]
+
+  """
+
+  # Split the position string into a list of position strings
+  start, end = position_range_string.split(":")
+  start, end = string_to_position(start), string_to_position(end)
+  positions = [[False for _ in range(12)] for _ in range(8)]
+  for row, column in itertools.product(range(start[0], end[0] + 1), range(start[1], end[1] + 1)):
+    positions[row][column] = True
+  return positions
diff --git a/pyhamilton/utils/string.py b/pyhamilton/utils/string.py
new file mode 100644
index 0000000..30e5d0c
--- /dev/null
+++ b/pyhamilton/utils/string.py
@@ -0,0 +1,25 @@
+""" Utilities for working with strings. """
+
+import typing
+
+
+def pad_string(item: typing.Union[str, int], desired_length: int, left=False) -> str:
+  """ Pad a string or integer with spaces to the desired length.
+
+  Args:
+    item: string or integer to pad
+    desired_length: length to pad to
+    left: pad to the left instead of the right
+
+  Returns:
+    padded string of length `desired_length`
+  """
+
+  length = None
+  if isinstance(item, str):
+    length = len(item)
+  elif isinstance(item, int):
+    length = item // 10
+  spaces = max(0, desired_length - length) * " "
+  item = str(item)
+  return (spaces+item) if left else (item+spaces)
diff --git a/setup.py b/setup.py
index 76970c3..4c35f44 100644
--- a/setup.py
+++ b/setup.py
@@ -1,29 +1,42 @@
 from setuptools import setup, find_packages
 
-try:
-    print("here")
-    import pypandoc
-    long_description = pypandoc.convert_file('README.md', 'rst')
-    print(long_description)
-except(IOError, ImportError):
-    long_description = open('README.md').read()
+long_description = open('README.md', encoding='utf-8').read()
+
+extras_docs = [
+    'sphinx_book_theme',
+    'myst_nb',
+    'sphinx_copybutton',
+]
+
+extras_simulation = [
+    'websockets'
+]
+
+extras_testing = [
+    'pytest',
+    'pytest-timeout',
+    'requests'
+] + extras_simulation
+
+extras_all =  extras_docs + extras_simulation + extras_testing
 
 setup(
     name='pyhamilton',
-    version='1.46',
-    packages=find_packages(exclude=['tests*', 'examples*']),
+    version='1.235',
+    packages=find_packages(exclude="tools"),
     license='MIT',
     description='Python for Hamilton liquid handling robots',
-    long_description='Forthcoming due to markdown incompatibility',
-    install_requires=['requests', 'pythonnet', 'pywin32', 'pyserial'],
-    package_data={'pyhamilton': ['star-oem/*', 'star-oem/VENUS_Method/*', 'bin/*','library/*']},
+    long_description=long_description,
+    long_description_content_type='text/markdown',
+    install_requires=['pyusb', 'websockets'],
+    package_data={'pyhamilton': ['star-oem/*', 'star-oem/VENUS_Method/*']},
     url='https://github.com/dgretton/pyhamilton.git',
     author='Dana Gretton',
     author_email='dgretton@mit.edu',
-    entry_points={
-        'console_scripts': [
-            'pyhamilton-quickstart = pyhamilton.cmd.quickstart:main',
-            'pyhamilton-config = pyhamilton.__init__:autoconfig'
-        ],
-    },
+    extras_require={
+        'testing': extras_testing,
+        'docs': extras_docs,
+        'simulation': extras_simulation,
+        'all': extras_all
+    }
 )
diff --git a/tools/README.md b/tools/README.md
new file mode 100644
index 0000000..8011ccf
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,4 @@
+# PyHamilton Tools
+
+- `make_fw`: script to make converting commands from the firmware documents into Python methods easier.
+- `make_resources`: scripts to create Python classes for various Method Editor resources.
diff --git a/tools/make_fw/make_fw.py b/tools/make_fw/make_fw.py
new file mode 100644
index 0000000..c9c1090
--- /dev/null
+++ b/tools/make_fw/make_fw.py
@@ -0,0 +1,149 @@
+import datetime
+
+
+class Parameter:
+  def __init__(
+    self,
+    parameter,
+    default,
+    name,
+    desc,
+    t,
+    min=None,
+    max=None
+  ):
+    self.parameter = parameter
+    self.default = default
+    self.name = name.replace(" ", "_").lower()
+    self.desc = desc
+    self.t = t
+    self.min=min
+    self.max=max
+
+  def formatted_type(self):
+    if self.t is int: return 'int'
+    if self.t is str: return 'str'
+    if self.t is bool: return 'bool'
+    if self.t is datetime.datetime: return 'datetime.datetime'
+    else: return 'TODO:'
+
+  def formatted_default(self):
+    if type(self.default) is str:
+      return f'"{self.default}"'
+    return self.default
+
+  def func_decl_line(self):
+    print(f'  {self.name}: {self.formatted_type()} = {self.formatted_default()}')
+
+  def doc_line(self):
+    docline = f'{self.name}: {self.desc}.'
+    if self.t is int:
+      if self.min is not None and self.max is not None:
+        docline += f' Must be between {self.min} and {self.max}.'
+      elif self.max is not None:
+        docline += f' Must be smaller than {self.max}.'
+      elif self.min is not None:
+        docline += f' Must be bigger than {self.min}.'
+    if self.default is not None:
+       docline += f' Default {self.formatted_default()}.'
+    print('    ' + docline)
+
+  def assertion_line(self):
+    if self.t is int and (self.min is not None or self.max is not None):
+      print(f'  _assert_clamp({self.name}, {self.min}, {self.max}, "{self.name}")')
+
+  def value_line(self):
+    print(f'    {self.parameter}={self.name},')
+
+
+class Command:
+  def __init__(
+    self,
+    dev,
+    cmd,
+    name,
+    desc=None,
+    args=[]
+  ):
+    self.dev = dev
+    self.cmd = cmd
+    self.name = name
+    self.desc = desc
+    self.args = args
+
+  def header(self):
+    if len(self.args) == 0:
+      print(f'def {self.funcname()}(self):')
+      return
+
+    nl = '\n'
+    print(f'def {self.funcname()}(')
+    print(f'  self,')
+    for arg in self.args:
+      arg.func_decl_line()
+    print(f'):')
+
+  def funcname(self):
+    return self.name.replace("-", "_").replace(" ", "_").lower()
+
+  def docstring(self):
+    nl = '\n'
+
+    if len(self.args) == 0 and self.desc is None:
+      print(f'  """ {self.name} """')
+      return
+
+    print(f'  """ {self.name}\n')
+    if self.desc is not None:
+      print(f'  {self.desc}\n')
+
+    if len(self.args) > 0:
+      print("  Args:")
+      for arg in self.args:
+          arg.doc_line()
+
+    print(f'  """')
+
+  def assertions(self):
+    for arg in self.args:
+      arg.assertion_line()
+    print()
+
+  def command(self):
+    if len(self.args) == 0:
+      print(f'  return self.send_command(device="{self.dev}", command="{self.cmd}")')
+      return
+
+    print('  return self.send_command(')
+    print('    module="{self.dev}",')
+    print('    command="{self.cmd}",')
+    for arg in self.args:
+      arg.value_line()
+    print('  )')
+
+  def export(self):
+    nl = '\n'
+    self.header()
+    self.docstring()
+    print()
+    self.assertions()
+    self.command()
+
+
+if __name__ == '__main__':
+  # TODO: Update this for each command.
+  cmd = Command(
+    name = "CommandName",
+    cmd = "RW",
+    dev = "C0",
+    desc=None,
+    args = [
+      Parameter(parameter="ph",
+                default=0,
+                name="Parameter name",
+                desc="Parameter description.",
+                t=int, min=0, max=4)
+    ]
+  )
+  cmd.export()
+
diff --git a/tools/make_resources/.gitignore b/tools/make_resources/.gitignore
new file mode 100644
index 0000000..8f0aa8e
--- /dev/null
+++ b/tools/make_resources/.gitignore
@@ -0,0 +1,5 @@
+*
+
+!make_*.py
+!maker.py
+!.gitignore
diff --git a/tools/make_resources/make_platecarriers.py b/tools/make_resources/make_platecarriers.py
new file mode 100644
index 0000000..ca865a8
--- /dev/null
+++ b/tools/make_resources/make_platecarriers.py
@@ -0,0 +1,63 @@
+""" Create Python representations of various VENUS Method Editor resources. """
+# pylint: skip-file
+
+import os
+import sys
+from maker import make
+
+sys.path.insert(0, '..')
+
+from pyhamilton.liquid_handling.resources.abstract import Coordinate, CarrierSite
+from pyhamilton.utils.file_parsing import find_int, find_float, find_string
+
+
+BASE_DIR = "../../LabWare/ML_STAR"
+BASE_CLASS = "PlateCarrier"
+OUT_FILE = "platecar.py"
+
+
+def make_from_file(fn, o):
+  with open(fn, 'r', encoding='ISO-8859-1') as f:
+    c = f.read()
+
+  site_count = int(c.split("Site.Cnt\x01")[1].split("\x08")[0])
+  sites = []
+  for i in range(1, site_count+1):
+    x = find_float(f"Site.{i}.X", c)
+    y = find_float(f"Site.{i}.Y", c)
+    z = find_float(f"Site.{i}.Z", c)
+    width = find_float(f"Site.{i}.Dx", c)
+    height = find_float(f"Site.{i}.Dy", c)
+    sites.append(CarrierSite(Coordinate(x, y, z), width, height))
+  sites = sorted(sites, key=lambda c: c.location.y)
+
+  size_x = find_float('Dim.Dx', c)
+  size_y = find_float('Dim.Dy', c)
+  size_z = find_float('Dim.Dz', c)
+  description = find_string("Description", c)
+  cname = os.path.basename(fn).split('.')[0]
+
+  o.write(f'\n\n')
+  o.write(f"class {cname}({BASE_CLASS}):\n")
+  o.write(f'  """ {description} """\n')
+  o.write('\n')
+  o.write(f'  def __init__(self, name: str):\n')
+  o.write(f'    super().__init__(\n')
+  o.write(f'      name=name,\n')
+  o.write(f'      size_x={size_x},\n')
+  o.write(f'      size_y={size_y},\n')
+  o.write(f'      size_z={size_z},\n')
+  o.write(f'      sites=[\n')
+  for i, site in enumerate(sites):
+    o.write(f'        {site.__repr__()}' + ('' if i == len(sites) - 1 else ',') + '\n')
+  o.write(f'      ]\n')
+  o.write(f'    )\n')
+
+
+if __name__ == "__main__":
+  make(
+    base_dir=BASE_DIR,
+    out_file=OUT_FILE,
+    pattern=r'PLT_CAR_[\S_]+.tml',
+    make_from_file=make_from_file
+  )
diff --git a/tools/make_resources/make_plates.py b/tools/make_resources/make_plates.py
new file mode 100644
index 0000000..fa4af17
--- /dev/null
+++ b/tools/make_resources/make_plates.py
@@ -0,0 +1,76 @@
+""" Create Python representations of various VENUS Method Editor resources. """
+# pylint: skip-file
+
+import os
+import sys
+from maker import make
+
+sys.path.insert(0, '..')
+
+from pyhamilton.liquid_handling.resources.abstract import Coordinate
+from pyhamilton.utils.file_parsing import find_int, find_float, find_string
+
+
+BASE_DIR = "../../LabWare/Corning-Costar"
+BASE_CLASS = "Plate"
+OUT_FILE = "plates.py"
+
+
+def make_from_file(fn, o):
+  with open(fn, 'r', encoding='ISO-8859-1') as f:
+    c = f.read()
+
+  size_x = find_float('Dim.Dx', c)
+  size_y = find_float('Dim.Dy', c)
+  size_z = find_float('Dim.Dz', c)
+  tip_type = None
+
+  dx = find_float('BndryX', c) or 0
+  dy = find_float('BndryY', c) or 0
+  dz = 0
+
+  cname = os.path.basename(fn).split('.')[0]
+  description = cname
+  EqnOfVol = None
+
+  # .rck to .ctr filename
+  def rck2ctr(fn):
+    return fn.replace("_P.rck", ".ctr").replace("_L.rck", ".ctr").replace(".rck", ".ctr") \
+      .replace("ProtCryst", "Post")
+
+  with open(rck2ctr(fn), 'r', encoding='ISO-8859-1') as f2:
+    c2 = f2.read()
+    EqnOfVol = find_string("1.EqnOfVol", c2)
+    dz = find_float("BaseMM", c2)
+
+  o.write(f'\n\n')
+  o.write(f"class {cname}({BASE_CLASS}):\n")
+  o.write(f'  """ {description} """\n')
+  o.write('\n')
+  o.write(f'  def __init__(\n')
+  o.write(f'    self,\n')
+  o.write(f'    name: str,\n')
+  o.write(f'    location: Coordinate = Coordinate(None, None, None),\n')
+  o.write(f'  ):\n')
+  o.write(f'    super().__init__(\n')
+  o.write(f'      name=name,\n')
+  o.write(f'      location=location,\n')
+  o.write(f'      size_x={size_x},\n')
+  o.write(f'      size_y={size_y},\n')
+  o.write(f'      size_z={size_z},\n')
+  o.write(f'      dx={dx},\n')
+  o.write(f'      dy={dy},\n')
+  o.write(f'      dz={dz}\n')
+  o.write(f'    )\n')
+  o.write(f'\n')
+  o.write(f'  def compute_volume_from_height(self, h):\n')
+  o.write(f'    return {EqnOfVol}\n')
+
+
+if __name__ == "__main__":
+  make(
+    base_dir=BASE_DIR,
+    out_file=OUT_FILE,
+    pattern=r'[\S]+\.rck',
+    make_from_file=make_from_file
+  )
diff --git a/tools/make_resources/make_tipcarriers.py b/tools/make_resources/make_tipcarriers.py
new file mode 100644
index 0000000..5d39366
--- /dev/null
+++ b/tools/make_resources/make_tipcarriers.py
@@ -0,0 +1,63 @@
+""" Create Python representations of various VENUS Method Editor resources. """
+# pylint: skip-file
+
+import os
+import sys
+from maker import make
+
+sys.path.insert(0, '..')
+
+from pyhamilton.liquid_handling.resources.abstract import Coordinate, CarrierSite
+from pyhamilton.utils.file_parsing import find_int, find_float, find_string
+
+
+BASE_DIR = "../../LabWare/ML_STAR"
+BASE_CLASS = "TipCarrier"
+OUT_FILE = "tipcar.py"
+
+
+def make_from_file(fn, o):
+  with open(fn, 'r', encoding='ISO-8859-1') as f:
+    c = f.read()
+
+  site_count = int(c.split("Site.Cnt\x01")[1].split("\x08")[0])
+  sites = []
+  for i in range(1, site_count+1):
+    x = find_float(f"Site.{i}.X", c)
+    y = find_float(f"Site.{i}.Y", c)
+    z = find_float(f"Site.{i}.Z", c)
+    width = find_float(f"Site.{i}.Dx", c)
+    height = find_float(f"Site.{i}.Dy", c)
+    sites.append(CarrierSite(Coordinate(x, y, z), width, height))
+  sites = sorted(sites, key=lambda c: c.location.y)
+
+  size_x = find_float('Dim.Dx', c)
+  size_y = find_float('Dim.Dy', c)
+  size_z = find_float('Dim.Dz', c)
+  description = find_string("Description", c)
+  cname = os.path.basename(fn).split('.')[0]
+
+  o.write(f'\n\n')
+  o.write(f"class {cname}({BASE_CLASS}):\n")
+  o.write(f'  """ {description} """\n')
+  o.write('\n')
+  o.write(f'  def __init__(self, name: str):\n')
+  o.write(f'    super().__init__(\n')
+  o.write(f'      name=name,\n')
+  o.write(f'      size_x={size_x},\n')
+  o.write(f'      size_y={size_y},\n')
+  o.write(f'      size_z={size_z},\n')
+  o.write(f'      sites=[\n')
+  for i, site in enumerate(sites):
+    o.write(f'        {site.__repr__()}' + ('' if i == len(sites) - 1 else ',') + '\n')
+  o.write(f'      ]\n')
+  o.write(f'    )\n')
+
+
+if __name__ == "__main__":
+  make(
+    base_dir=BASE_DIR,
+    out_file=OUT_FILE,
+    pattern=r'TIP_CAR_[\S_]+.tml',
+    make_from_file=make_from_file
+  )
diff --git a/tools/make_resources/make_tips.py b/tools/make_resources/make_tips.py
new file mode 100644
index 0000000..4006461
--- /dev/null
+++ b/tools/make_resources/make_tips.py
@@ -0,0 +1,67 @@
+""" Create Python representations of various VENUS Method Editor resources. """
+# pylint: skip-file
+
+import os
+import sys
+from maker import make
+
+sys.path.insert(0, '..')
+
+from pyhamilton.liquid_handling.resources.abstract import Coordinate
+from pyhamilton.utils.file_parsing import find_int, find_float, find_string
+
+
+BASE_DIR = "../../LabWare/ML_STAR"
+BASE_CLASS = "Tips"
+OUT_FILE = "tips.py"
+
+
+def make_from_file(fn, o):
+  with open(fn, 'r', encoding='ISO-8859-1') as f:
+    c = f.read()
+
+  size_x = find_float('Dim.Dx', c)
+  size_y = find_float('Dim.Dy', c)
+  size_z = find_float('Dim.Dz', c)
+  tip_type = None
+  try:
+    tip_type = find_string("PropertyValue.6", c)
+  except ValueError:
+    tip_type = find_string("PropertyValue.4", c)
+
+  dx = find_float('BndryX', c) or 0
+  dy = find_float('BndryY', c) or 0
+  dz = find_float('Cntr.1.base', c) or 0
+
+  cname = os.path.basename(fn).split('.')[0]
+  if cname[0] == '4': cname = 'Four' + cname[1:]
+  elif cname[0] == '5': cname = 'Five' + cname[1:]
+  description = find_string("Description", c)
+  EqnOfVol = None
+
+  o.write(f'\n\n')
+  o.write(f"class {cname}({BASE_CLASS}):\n")
+  o.write(f'  """ {description} """\n')
+  o.write('\n')
+  o.write(f'  def __init__(self, name: str):\n')
+  o.write(f'    super().__init__(\n')
+  o.write(f'      name=name,\n')
+  o.write(f'      size_x={size_x},\n')
+  o.write(f'      size_y={size_y},\n')
+  o.write(f'      size_z={size_z},\n')
+  o.write(f'      dx={dx},\n')
+  o.write(f'      dy={dy},\n')
+  o.write(f'      dz={dz}\n')
+  o.write(f'    )\n')
+  o.write(f'\n')
+  o.write(f'  def compute_volume_from_height(self, h):\n')
+  o.write(f'    return {EqnOfVol}\n')
+
+
+if __name__ == "__main__":
+  make(
+    base_dir=BASE_DIR,
+    out_file=OUT_FILE,
+    pattern=r'[\S]+TF?_L\.rck',
+    make_from_file=make_from_file
+  )
diff --git a/tools/make_resources/maker.py b/tools/make_resources/maker.py
new file mode 100644
index 0000000..53b8001
--- /dev/null
+++ b/tools/make_resources/maker.py
@@ -0,0 +1,16 @@
+import os
+import re
+
+
+def make(base_dir, out_file, pattern, make_from_file):
+  pattern = re.compile(pattern)
+
+  with open(out_file, 'w', encoding='utf-8') as o:
+    for fn in os.listdir(base_dir):
+      if pattern.match(fn):
+        fn = os.path.join(base_dir, fn)
+        make_from_file(fn, o)
+        print(f"[DONE] {fn}")
+      else:
+        fn = os.path.join(base_dir, fn)
+        print(f"[SKIP] {fn}")


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..9ac2e4b
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,23 @@
+name: Unit Tests
+
+on: [push, pull_request]
+
+jobs:
+  test:
+    name: Tests
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Checkout Code
+      uses: actions/checkout@v2
+    - name: Update packages
+      run: sudo apt-get update
+    - name: Set up Python 3.9
+      uses: actions/setup-python@v2
+      with:
+        python-version: 3.9
+    - name: Install Dependencies
+      run: pip install -e '.[testing]'
+    - name: Run Pytest
+      run: python -m pytest -s -v
+
diff --git a/pyhamilton/liquid_handling/backends/hamilton/STAR_tests.py b/pyhamilton/liquid_handling/backends/hamilton/STAR_tests.py
new file mode 100644
index 0000000..7c7d1c0
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/hamilton/STAR_tests.py
@@ -0,0 +1,342 @@
+""" Tests for Hamilton backend. """
+
+import unittest
+
+from pyhamilton.liquid_handling.liquid_handler import LiquidHandler
+from pyhamilton.liquid_handling.resources import (
+  TIP_CAR_480_A00,
+  PLT_CAR_L5AC_A00,
+  Cos_96_DW_1mL,
+  TipType,
+  Cos_96_DW_500ul,
+  standard_volume_tip_with_filter,
+)
+from pyhamilton.liquid_handling.resources.ml_star import STF_L, HTF_L
+
+from .STAR import STAR
+from .errors import (
+  CommandSyntaxError,
+  HamiltonFirmwareError,
+  NoTipError,
+  HardwareError,
+  UnknownHamiltonError
+)
+
+
+class TestSTARResponseParsing(unittest.TestCase):
+  """ Test parsing of response from Hamilton. """
+
+  def setUp(self):
+    super().setUp()
+    self.star = STAR()
+
+  def test_parse_response_params(self):
+    parsed = self.star.parse_response("C0QMid1111", "")
+    self.assertEqual(parsed, {"id": 1111})
+
+    parsed = self.star.parse_response("C0QMid1111", "id####")
+    self.assertEqual(parsed, {"id": 1111})
+
+    parsed = self.star.parse_response("C0QMid1112aaabc", "aa&&&")
+    self.assertEqual(parsed, {"id": 1112, "aa": "abc"})
+
+    parsed = self.star.parse_response("C0QMid1112aa-21", "aa##")
+    self.assertEqual(parsed, {"id": 1112, "aa": -21})
+
+    parsed = self.star.parse_response("C0QMid1113pqABC", "pq***")
+    self.assertEqual(parsed, {"id": 1113, "pq": int("ABC", base=16)})
+
+    with self.assertRaises(ValueError):
+      # should fail with auto-added id.
+      parsed = self.star.parse_response("C0QMaaabc", "")
+      self.assertEqual(parsed, "")
+
+    with self.assertRaises(ValueError):
+      self.star.parse_response("C0QM", "id####") # pylint: disable=expression-not-assigned
+
+    with self.assertRaises(ValueError):
+      self.star.parse_response("C0RV", "") # pylint: disable=expression-not-assigned
+
+  def test_parse_response_no_errors(self):
+    parsed = self.star.parse_response("C0QMid1111", "")
+    self.assertEqual(parsed, {"id": 1111})
+
+    parsed = self.star.parse_response("C0QMid1111 er00/00", "")
+    self.assertEqual(parsed, {"id": 1111})
+
+    parsed = self.star.parse_response("C0QMid1111 er00/00 P100/00", "")
+    self.assertEqual(parsed, {"id": 1111})
+
+  def test_parse_response_master_error(self):
+    with self.assertRaises(HamiltonFirmwareError) as ctx:
+      self.star.parse_response("C0QMid1111 er01/30", "")
+    e = ctx.exception
+    self.assertEqual(len(e), 1)
+    self.assertIn("Master", e)
+    self.assertIsInstance(e["Master"], CommandSyntaxError)
+    self.assertEqual(e["Master"].message, "Unknown command")
+
+  def test_parse_response_slave_errors(self):
+    with self.assertRaises(HamiltonFirmwareError) as ctx:
+      self.star.parse_response("C0QMid1111 er99/00 P100/00 P235/00 P402/98 PG08/76", "")
+    e = ctx.exception
+    self.assertEqual(len(e), 3)
+    self.assertNotIn("Master", e)
+    self.assertNotIn("Pipetting channel 1", e)
+    self.assertEqual(e["Pipetting channel 2"].raw_response, "35/00")
+    self.assertEqual(e["Pipetting channel 4"].raw_response, "02/98")
+    self.assertEqual(e["Pipetting channel 16"].raw_response, "08/76")
+
+    self.assertIsInstance(e["Pipetting channel 2"], UnknownHamiltonError)
+    self.assertIsInstance(e["Pipetting channel 4"], HardwareError)
+    self.assertIsInstance(e["Pipetting channel 16"], NoTipError)
+
+    self.assertEqual(e["Pipetting channel 2"].message, "No error")
+    self.assertEqual(e["Pipetting channel 4"].message, "Unknown trace information code 98")
+    self.assertEqual(e["Pipetting channel 16"].message, "Tip already picked up")
+
+  def test_parse_slave_response_errors(self):
+    with self.assertRaises(HamiltonFirmwareError) as ctx:
+      self.star.parse_response("P1OQid1111er30", "")
+
+    e = ctx.exception
+    self.assertEqual(len(e), 1)
+    self.assertNotIn("Master", e)
+    self.assertIn("Pipetting channel 1", e)
+    self.assertIsInstance(e["Pipetting channel 1"], UnknownHamiltonError)
+    self.assertEqual(e["Pipetting channel 1"].message, "Unknown command")
+
+
+class STARCommandCatcher(STAR):
+  """ Mock backend for star that catches commands and saves them instad of sending them to the
+  machine. """
+
+  def __init__(self):
+    super().__init__()
+    self.commands = []
+
+  def setup(self):
+    self.setup_finished = True
+
+  def send_command(self, module, command, **kwargs):
+    cmd, _ = self._assemble_command(module, command, **kwargs)
+    self.commands.append(cmd)
+
+
+class TestSTARLiquidHandlerCommands(unittest.TestCase):
+  """ Test STAR backend for liquid handling. """
+
+  def setUp(self):
+    # pylint: disable=invalid-name
+    self.mockSTAR = STARCommandCatcher()
+    self.lh = LiquidHandler(self.mockSTAR)
+
+    tip_car = TIP_CAR_480_A00(name="tip carrier")
+    tip_car[1] = STF_L(name="tips_01")
+    self.lh.assign_resource(tip_car, rails=1)
+
+    plt_car = PLT_CAR_L5AC_A00(name="plate carrier")
+    plt_car[0] = Cos_96_DW_1mL(name="plate_01")
+    self.lh.assign_resource(plt_car, rails=9)
+
+    self.maxDiff = None
+
+    self.lh.setup()
+
+  def _assert_command_in_command_buffer(self, cmd: str, should_be: bool, fmt: str):
+    """ Assert that the given command was sent to the backend. The ordering of the parameters is not
+    taken into account, but the values and formatting should match. The id parameter of the command
+    is ignored.
+
+    Args:
+      cmd: the command to look for
+      should_be: whether the command should be found or not
+      fmt: the format of the command
+    """
+
+    found = False
+    # Command that fits the format, but is not the same as the command we are looking for.
+    similar = None
+
+    parsed_cmd = self.mockSTAR.parse_fw_string(cmd, fmt)
+    parsed_cmd.pop("id")
+
+    for sent_cmd in self.mockSTAR.commands:
+      # When the module and command do not match, there is no point in comparing the parameters.
+      if sent_cmd[0:4] != cmd[0:4]:
+        continue
+
+      try:
+        parsed_sent_cmd = self.mockSTAR.parse_fw_string(sent_cmd, fmt)
+        parsed_sent_cmd.pop("id")
+
+        if parsed_cmd == parsed_sent_cmd:
+          self.mockSTAR.commands.remove(sent_cmd)
+          found = True
+          break
+        else:
+          similar = parsed_sent_cmd
+      except ValueError as e:
+        # The command could not be parsed.
+        print(e)
+        continue
+
+    if should_be and not found:
+      if similar is not None:
+        # These will not be equal, but this method does give a better error message than `fail`.
+        self.assertEqual(similar, parsed_cmd)
+      else:
+        self.fail(f"Command {cmd} not found in sent commands: {self.mockSTAR.commands}")
+    elif not should_be and found:
+      self.fail(f"Command {cmd} was found in sent commands: {self.mockSTAR.commands}")
+
+  def test_channel_positions_to_fw_positions(self):
+    """ Convert channel positions to firmware positions. """
+    resource = self.lh.get_resource("tips_01")
+    self.assertEqual(
+      self.mockSTAR._channel_positions_to_fw_positions(["A1"], resource),
+      ([1179, 0], [2418, 0], [True, False])
+    )
+
+    self.assertEqual(
+      self.mockSTAR._channel_positions_to_fw_positions(["A1", "F1"], resource),
+      ([1179, 1179, 0], [2418, 1968, 0], [True, True, False])
+    )
+
+  def _assert_command_sent_once(self, cmd: str, fmt: str):
+    """ Assert that the given command was sent to the backend exactly once. """
+    self._assert_command_in_command_buffer(cmd, True, fmt)
+    self._assert_command_in_command_buffer(cmd, False, fmt)
+
+  def test_tip_definition(self):
+    pass
+
+  def test_tip_pickup_01(self):
+    self.lh.pickup_tips("tips_01", "A1", "B1")
+    self._assert_command_sent_once(
+      "C0TPid0000xp01179 01179 00000&yp2418 2328 0000&tm1 1 0&tt01tp2244tz2164th2450td0",
+      "xp##### (n)yp#### (n)tm# (n)tt##tp####tz####th####td#")
+
+  def test_tip_pickup_56(self):
+    self.lh.pickup_tips("tips_01", channel_5="E1", channel_6="F1")
+    self._assert_command_sent_once(
+      "C0TPid0000xp01179 01179 00000&yp2058 1968 0000&tm1 1 0&tt01tp2244tz2164th2450td0",
+      "xp##### (n)yp#### (n)tm# (n)tt##tp####tz####th####td#")
+
+  def test_tip_pickup_16(self):
+    self.lh.pickup_tips("tips_01", channel_1="A1", channel_5="F1")
+    self._assert_command_sent_once(
+      "C0TPid0000xp01179 01179 00000&yp2418 1968 0000&tm1 1 0&tt01tp2244tz2164th2450td0",
+      "xp##### (n)yp#### (n)tm# (n)tt##tp####tz####th####td#")
+
+  def test_tip_discard_56(self):
+    self.test_tip_pickup_56() # pick up tips first
+    self.lh.discard_tips("tips_01", channel_5="E1", channel_6="F1")
+    self._assert_command_sent_once(
+      "C0TRid0000xp01179 01179 00000&yp2058 1968 0000&tm1 1 0&tt01tp2244tz2164th2450ti0",
+      "xp##### (n)yp#### (n)tm# (n)tt##tp####tz####th####ti#")
+
+  def test_single_channel_aspiration(self):
+    self.lh.pickup_tips("tips_01", "A1")
+    self.lh.aspirate("plate_01", ("A1", 100))
+
+    # Real command, but with extra parameters. `lp`, `zl`, `zx`, `av`, `zu`, `zr` changed
+    # TODO: Do we need these parameters with the real robot?
+    # self._assert_command_sent_once(
+    #   "C0ASid0300at0&tm1 0&xp02980 00000&yp1460 0000&th2450te2450lp2321 2450&ch000&zl1881 2450&"
+    #   "zx1871 0000&ip0000&it0&fp0000&av01072 00000&as1000&ta000&ba0000&oa000&lm0&ll1&lv1&ld00&"
+    #   "de0020&wt10&mv00000&mc00&mp000&ms1000&gi000&gj0gk0zu0032 0000&zr06180 00000&mh0000&zo000&"
+    #   "po0100&lk0&ik0000&sd0500&se0500&sz0300&io0000&il00000&in0000&",
+    #   fmt="at# (n)tm# (n)xp##### (n)yp#### (n)th####te####lp#### (n)ch### (n)zl#### (n)zx#### (n)"
+    #   "ip#### (n)it# (n)fp#### (n)av#### (n)as#### (n)ta### (n)ba#### (n)oa### (n)lm# (n)ll# (n)"
+    #   "lv# (n)ld## (n)de#### (n)wt## (n)mv##### (n)mc## (n)mp### (n)ms#### (n)gi### (n)gj#gk#"
+    #   "zu#### (n)zr#### (n)mh#### (n)zo### (n)po#### (n)lk# (n)ik#### (n)sd#### (n)se#### (n)"
+    #   "sz#### (n)io#### (n)il##### (n)in#### (n)")
+
+    # This passes the test, but is not the real command.
+    self._assert_command_sent_once(
+      "C0ASid0000at0&tm1 0&xp02980 00000&yp1460 0000&th2450te2450lp2321&ch000&zl1881&"
+      "zx1871&ip0000&it0&fp0000&av01072&as1000&ta000&ba0000&oa000&lm0&ll1&lv1&ld00&"
+      "de0020&wt10&mv00000&mc00&mp000&ms1000&gi000&gj0gk0zu0032&zr06180&mh0000&zo000&"
+      "po0100&lk0&ik0000&sd0500&se0500&sz0300&io0000&il00000&in0000&",
+      fmt="at# (n)tm# (n)xp##### (n)yp#### (n)th####te####lp#### (n)ch### (n)zl#### (n)zx#### (n)"
+      "ip#### (n)it# (n)fp#### (n)av#### (n)as#### (n)ta### (n)ba#### (n)oa### (n)lm# (n)ll# (n)"
+      "lv# (n)ld## (n)de#### (n)wt## (n)mv##### (n)mc## (n)mp### (n)ms#### (n)gi### (n)gj#gk#"
+      "zu#### (n)zr#### (n)mh#### (n)zo### (n)po#### (n)lk# (n)ik#### (n)sd#### (n)se#### (n)"
+      "sz#### (n)io#### (n)il##### (n)in#### (n)")
+
+  def test_multi_channel_aspiration(self):
+    self.lh.pickup_tips("tips_01", "A1", "B1")
+    self.lh.aspirate("plate_01", ("A1", 100), ("B1", 100))
+
+    # Real command
+    # self._assert_command_sent_once(
+    #   "C0ASid0225at0&tm1 1 0&xp02980 02980 00000&yp1460 1370 0000&th2450te2450lp2321 2321 2450&"
+    #   "ch000 000&zl1881 1881 2450&zx1871 1871 0000&ip0000 0000&it0 0&fp0000 0000&"
+    #   "av01072 01072 00000&as1000 1000&ta000 000&ba0000 0000&oa000 000&lm0 0&ll1 1&lv1 1&ld00 00&"
+    #   "de0020 0020&wt10 10&mv00000 00000&mc00 00&mp000 000&ms1000 1000&gi000 000&gj0gk0"
+    #   "zu0032 0032 0000&zr06180 06180 00000&mh0000 0000&zo000 000&po0100 0100&lk0 0&ik0000 0000&"
+    #   "sd0500 0500&se0500 0500&sz0300 0300&io0000 0000&il00000 00000&in0000 0000&",
+    #   fmt="at# (n)tm# (n)xp##### (n)yp#### (n)th####te####lp#### (n)ch### (n)zl#### (n)zx#### (n)"
+    #   "ip#### (n)it# (n)fp#### (n)av#### (n)as#### (n)ta### (n)ba#### (n)oa### (n)lm# (n)ll# (n)"
+    #   "lv# (n)ld## (n)de#### (n)wt## (n)mv##### (n)mc## (n)mp### (n)ms#### (n)gi### (n)gj#gk#"
+    #   "zu#### (n)zr#### (n)mh#### (n)zo### (n)po#### (n)lk# (n)ik#### (n)sd#### (n)se#### (n)"
+    #   "sz#### (n)io#### (n)il##### (n)in#### (n)")
+
+    # This passes the test, but is not the real command.
+    self._assert_command_sent_once(
+      "C0ASid0000at0&tm1 1 0&xp02980 02980 00000&yp1460 1370 0000&th2450te2450lp2321 2321&"
+      "ch000 000&zl1881 1881&zx1871 1871&ip0000 0000&it0 0&fp0000 0000&"
+      "av01072 01072&as1000 1000&ta000 000&ba0000 0000&oa000 000&lm0 0&ll1 1&lv1 1&ld00 00&"
+      "de0020 0020&wt10 10&mv00000 00000&mc00 00&mp000 000&ms1000 1000&gi000 000&gj0gk0"
+      "zu0032 0032&zr06180 06180&mh0000 0000&zo000 000&po0100 0100&lk0 0&ik0000 0000&"
+      "sd0500 0500&se0500 0500&sz0300 0300&io0000 0000&il00000 00000&in0000 0000&",
+      fmt="at# (n)tm# (n)xp##### (n)yp#### (n)th####te####lp#### (n)ch### (n)zl#### (n)zx#### (n)"
+      "ip#### (n)it# (n)fp#### (n)av#### (n)as#### (n)ta### (n)ba#### (n)oa### (n)lm# (n)ll# (n)"
+      "lv# (n)ld## (n)de#### (n)wt## (n)mv##### (n)mc## (n)mp### (n)ms#### (n)gi### (n)gj#gk#"
+      "zu#### (n)zr#### (n)mh#### (n)zo### (n)po#### (n)lk# (n)ik#### (n)sd#### (n)se#### (n)"
+      "sz#### (n)io#### (n)il##### (n)in#### (n)")
+
+  def test_single_channel_dispense(self):
+    self.lh.pickup_tips("tips_01", "A1")
+    self.lh.dispense("plate_01", ("A1", 100))
+    self._assert_command_sent_once(
+      "C0DSid0000dm2&tm1 0&xp02980 00000&yp1460 0000&zx1871&lp2321&zl1881&"
+      "ip0000&it0&fp0000&th2450te2450dv01072&ds1200&ss0050&rv000&ta000&ba0000&lm0&zo000&ll1&"
+      "lv1&de0020&mv00000&mc00&mp000&ms0010&wt00&gi000&gj0gk0zu0032&dj00zr06180&"
+      " mh0000&po0100&",
+      "dm# (n)tm# (n)xp##### (n)yp#### (n)zx#### (n)lp#### (n)zl#### (n)ip#### (n)it# (n)fp#### (n)"
+      "th####te####dv##### (n)ds#### (n)ss#### (n)rv### (n)ta### (n)ba#### (n)lm# (n)zo### (n)"
+      "ll# (n)lv# (n)de#### (n)mv##### (n)mc## (n)mp### (n)ms#### (n)wt## (n)gi### (n)gj#gk#"
+      "zu#### (n)zr##### (n)mh#### (n)po#### (n)")
+
+  def test_multi_channel_dispense(self):
+    self.lh.pickup_tips("tips_01", "A1", "B1")
+    self.lh.dispense("plate_01", ("A1", 100), ("B1", 100))
+
+    # self._assert_command_sent_once(
+    #   "C0DSid0317dm2 2&tm1 1 0&dv01072 01072 00000&xp02980 02980 00000&yp1460 1370 0000&"
+    #   "zx1871 1871 0000&lp2321 2321 2450&zl1881 1881 2450&ip0000 0000&it0 0&fp0000 0000&th2450"
+    #   "te2450ds1200 1200&ss0050 0050&rv000 000&ta000 000&ba0000 0000&lm0 0&zo000 000&ll1 1&",
+    #   "lv1 1&de0020 0020&mv00000 00000&mc00 00&mp000 000&ms0010 0010&wt00 00&gi000 000&gj0gk0"
+    #   "zu0032 0032 0000&dj00zr06180 06180 00000&mh0000 0000&po0100 0100&",
+    #   "dm# (n)tm# (n)xp##### (n)yp#### (n)zx#### (n)lp#### (n)zl#### (n)ip#### (n)it# (n)fp#### (n)"
+    #   "th####te####dv##### (n)ds#### (n)ss#### (n)rv### (n)ta### (n)ba#### (n)lm# (n)zo### (n)"
+    #   "ll# (n)lv# (n)de#### (n)mv##### (n)mc## (n)mp### (n)ms#### (n)wt## (n)gi### (n)gj#gk#"
+    #   "zu#### (n)zr##### (n)mh#### (n)po#### (n)")
+    # modified to remove additional values in parameters (dm, dv, lp, zl, zr, zu, zx)
+
+    self._assert_command_sent_once(
+      "C0DSid0317dm2 2&tm1 1 0&dv01072 01072&xp02980 02980 00000&yp1460 1370 0000&"
+      "zx1871 1871&lp2321 2321&zl1881 1881&ip0000 0000&it0 0&fp0000 0000&th2450"
+      "te2450ds1200 1200&ss0050 0050&rv000 000&ta000 000&ba0000 0000&lm0 0&zo000 000&ll1 1&"
+      "lv1 1&de0020 0020&mv00000 00000&mc00 00&mp000 000&ms0010 0010&wt00 00&gi000 000&gj0gk0"
+      "zu0032 0032&dj00zr06180 06180&mh0000 0000&po0100 0100&",
+      "dm# (n)tm# (n)xp##### (n)yp#### (n)zx#### (n)lp#### (n)zl#### (n)ip#### (n)it# (n)fp#### (n)"
+      "th####te####dv##### (n)ds#### (n)ss#### (n)rv### (n)ta### (n)ba#### (n)lm# (n)zo### (n)"
+      "ll# (n)lv# (n)de#### (n)mv##### (n)mc## (n)mp### (n)ms#### (n)wt## (n)gi### (n)gj#gk#"
+      "zu#### (n)zr##### (n)mh#### (n)po#### (n)")
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/pyhamilton/liquid_handling/backends/simulation/simulation_tests.py b/pyhamilton/liquid_handling/backends/simulation/simulation_tests.py
new file mode 100644
index 0000000..ebafec0
--- /dev/null
+++ b/pyhamilton/liquid_handling/backends/simulation/simulation_tests.py
@@ -0,0 +1,217 @@
+""" Tests for the simulation backend. """
+
+import asyncio
+import json
+import time
+import unittest
+
+import pytest
+import requests
+import websockets
+
+from pyhamilton.liquid_handling import LiquidHandler
+from pyhamilton.liquid_handling.backends.simulation import SimulationBackend
+from pyhamilton.liquid_handling.resources import (
+  TIP_CAR_480_A00,
+  PLT_CAR_L5AC_A00,
+  Cos_96_DW_1mL,
+  STF_L
+)
+
+class SimulatorBackendSetupStopTests(unittest.TestCase):
+  """ Tests for the setup and stop methods of the simulator backend. """
+
+  @pytest.mark.timeout(20)
+  def test_setup_stop(self):
+    """ Test that the thread is started and stopped correctly. """
+
+    backend = SimulationBackend(open_browser=False)
+    backend.setup()
+    time.sleep(2)
+    self.assertIsNotNone(backend.loop)
+    backend.stop()
+    self.assertIsNone(backend.websocket)
+
+    backend.setup()
+    time.sleep(2)
+    self.assertIsNotNone(backend.loop)
+    backend.stop()
+    self.assertIsNone(backend.websocket)
+
+class SimulatorBackendServerTests(unittest.IsolatedAsyncioTestCase):
+  """ Tests for servers (ws/fs). """
+
+  def setUp(self):
+    super().setUp()
+    self.backend = SimulationBackend(open_browser=False)
+    self.backend.setup()
+
+  async def asyncSetUp(self):
+    await super().asyncSetUp()
+
+    self.uri = "ws://localhost:2121"
+    self.client = await websockets.connect(self.uri)
+
+  def tearDown(self):
+    super().tearDown()
+    self.backend.stop()
+
+  async def asyncTearDown(self):
+    await super().asyncTearDown()
+    await self.client.close()
+
+  def test_get_index_html(self):
+    """ Test that the index.html file is returned. """
+    r = requests.get("http://localhost:1337/")
+    self.assertEqual(r.status_code, 200)
+    self.assertEqual(r.headers["Content-Type"], "text/html")
+
+  async def test_connect(self):
+    await self.client.send('{"event": "ready"}')
+    response = await self.client.recv()
+    self.assertEqual(response, '{"event": "ready"}')
+
+  async def test_event_sent(self):
+    await self.client.send('{"event": "ready"}')
+    response = await self.client.recv()
+    self.assertEqual(response, '{"event": "ready"}')
+
+    self.backend.send_event("test", wait_for_response=False)
+    recv = await self.client.recv()
+    data = json.loads(recv)
+    self.assertEqual(data["event"], "test")
+
+
+class SimulatorBackendEventCatcher(SimulationBackend):
+  """ Catches events that would be sent over the websocket for easy testing. """
+  def __init__(self):
+    super().__init__(open_browser=False)
+    self.sent_datas = []
+
+  def setup(self):
+    self.setup_finished = True
+
+  def send_event(self, event: str, wait_for_response: bool = True, **kwargs):
+    id_ = super()._generate_id()
+    data = dict(event=event, id=id_, **kwargs)
+    self.sent_datas.append(data)
+
+  def stop(self):
+    pass
+
+  def clear(self):
+    self.sent_datas.clear()
+
+  def get_command(self, event):
+    for data in self.sent_datas:
+      if data["event"] == event:
+        self.sent_datas.remove(data)
+        return data
+    return None
+
+
+class SimulatorBackendCommandTests(unittest.TestCase):
+  """ Tests for command sending using LiquidHandler. """
+
+  def setUp(self):
+    self.backend = SimulatorBackendEventCatcher()
+    self.lh = LiquidHandler(self.backend)
+    self.tip_car = TIP_CAR_480_A00("tip_car")
+    self.tip_car[0] = STF_L("tips_1")
+    self.plt_car = PLT_CAR_L5AC_A00("plt_car")
+    self.plt_car[0] = Cos_96_DW_1mL("plate_1")
+    self.lh.assign_resource(self.tip_car, rails=1)
+    self.lh.assign_resource(self.plt_car, rails=10)
+    self.lh.setup()
+
+  def assert_event_sent(self, event):
+    self.assertIsNotNone(self.backend.get_command(event))
+
+  def assert_event_not_sent(self, event):
+    self.assertIsNone(self.backend.get_command(event))
+
+  def assert_event_sent_once(self, event):
+    self.assert_event_sent(event)
+    self.assert_event_not_sent(event)
+
+  def test_resources_assigned_setup(self):
+    self.assert_event_sent("resource_assigned")
+    self.assert_event_sent("resource_assigned")
+    self.assert_event_not_sent("resource_assigned")
+
+  def test_resources_assigned(self):
+    self.backend.clear()
+    tip_car = TIP_CAR_480_A00("tip_car_new")
+    tip_car[0] = STF_L("tips_1_new")
+    self.lh.assign_resource(tip_car, rails=20)
+    self.assert_event_sent_once("resource_assigned")
+
+  def test_subresource_assigned(self):
+    self.backend.clear()
+    tip_car = TIP_CAR_480_A00("tip_car_new")
+    self.lh.assign_resource(tip_car, rails=20)
+    self.assert_event_sent_once("resource_assigned")
+
+    tip_car[0] = STF_L("tips_1_new")
+    self.assert_event_sent_once("resource_assigned")
+    self.assert_event_sent_once("resource_unassigned")
+
+    tip_car[0] = None
+    self.assert_event_sent_once("resource_assigned")
+    self.assert_event_sent_once("resource_unassigned")
+
+  def test_tip_pickup(self):
+    self.lh.pickup_tips("tip_car", "A1")
+    self.assert_event_sent_once("pickup_tips")
+
+  def test_discard_tips(self):
+    self.lh.discard_tips("tip_car", "A1")
+    self.assert_event_sent_once("discard_tips")
+
+  def test_aspirate(self):
+    self.lh.aspirate("plt_car", ("A1", 100))
+    self.assert_event_sent_once("aspirate")
+
+  def test_dispense(self):
+    self.lh.dispense("plt_car", ("A1", 100))
+    self.assert_event_sent_once("dispense")
+
+  def test_pickup_tips96(self):
+    self.lh.pickup_tips96("tip_car")
+    self.assert_event_sent_once("pickup_tips96")
+
+  def test_discard_tips96(self):
+    self.lh.discard_tips96("tip_car")
+    self.assert_event_sent_once("discard_tips96")
+
+  def test_aspirate96(self):
+    self.lh.aspirate96("plt_car", [[True]*12]*8, 100)
+    self.assert_event_sent_once("aspirate96")
+
+  def test_dispense96(self):
+    self.lh.dispense96("plt_car", [[True]*12]*8, 100)
+    self.assert_event_sent_once("dispense96")
+
+  def test_adjust_volume(self):
+    self.backend.adjust_well_volume(self.plt_car[0], [[100]*12]*8)
+    self.assert_event_sent_once("adjust_well_volume")
+
+  def test_place_tips(self):
+    self.backend.place_tips(self.tip_car[0], [[True]*12]*8)
+    self.assert_event_sent_once("edit_tips")
+
+  def test_fill_tips(self):
+    self.backend.fill_tips(self.tip_car[0])
+    self.assert_event_sent_once("edit_tips")
+
+  def test_remove_tips(self):
+    self.backend.remove_tips(self.tip_car[0], [[True]*12]*8)
+    self.assert_event_sent_once("edit_tips")
+
+  def test_clear_tips(self):
+    self.backend.clear_tips(self.tip_car[0])
+    self.assert_event_sent_once("edit_tips")
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/pyhamilton/liquid_handling/liquid_classes_tests.py b/pyhamilton/liquid_handling/liquid_classes_tests.py
new file mode 100644
index 0000000..6fb57e4
--- /dev/null
+++ b/pyhamilton/liquid_handling/liquid_classes_tests.py
@@ -0,0 +1,98 @@
+import unittest
+
+from .liquid_classes import LiquidClass, LiquidDevice, TipType
+
+
+class TestLiquidClass(unittest.TestCase):
+  """ Tests for liquid classes """
+
+  def test_auto_zero(self):
+    # empty correction curve should not have 0
+    lc = LiquidClass(
+      device=[LiquidDevice.CHANNELS_1000uL],
+      tip_type=TipType.HIGH_VOLUME_TIP_WITH_FILTER_1000uL,
+      dispense_mode=2,
+      pressure_lld=0,
+      max_height_difference=0,
+      flow_rate=(100, 180),
+      mix_flow_rate=(75, 75),
+      air_transport_volume=(5, 5),
+      blowout_volume=(30, 30),
+      swap_speed=(2, 2),
+      settling_time=(1, 0),
+      over_aspirate_volume=0,
+      clot_retract_height=0,
+      stop_flow_rate=100,
+      stop_back_volume=0,
+      correction_curve={}
+    )
+    self.assertNotIn(0, lc.correction_curve)
+
+    # non-empty correction curve auto-has 0
+    lc2 = LiquidClass(
+      device=[LiquidDevice.CHANNELS_1000uL],
+      tip_type=TipType.HIGH_VOLUME_TIP_WITH_FILTER_1000uL,
+      dispense_mode=2,
+      pressure_lld=0,
+      max_height_difference=0,
+      flow_rate=(100, 180),
+      mix_flow_rate=(75, 75),
+      air_transport_volume=(5, 5),
+      blowout_volume=(30, 30),
+      swap_speed=(2, 2),
+      settling_time=(1, 0),
+      over_aspirate_volume=0,
+      clot_retract_height=0,
+      stop_flow_rate=100,
+      stop_back_volume=0,
+      correction_curve={40: 42}
+    )
+    self.assertIn(0, lc2.correction_curve)
+
+
+  def test_compute_corrected_volume(self):
+    # test volume correction
+    lc = LiquidClass(
+      device=[LiquidDevice.CHANNELS_1000uL],
+      tip_type=TipType.HIGH_VOLUME_TIP_WITH_FILTER_1000uL,
+      dispense_mode=2,
+      pressure_lld=0,
+      max_height_difference=0,
+      flow_rate=(100, 180),
+      mix_flow_rate=(75, 75),
+      air_transport_volume=(5, 5),
+      blowout_volume=(30, 30),
+      swap_speed=(2, 2),
+      settling_time=(1, 0),
+      over_aspirate_volume=0,
+      clot_retract_height=0,
+      stop_flow_rate=100,
+      stop_back_volume=0,
+      correction_curve={1: 2, 2: 8, 3: 27}
+    )
+
+    self.assertIn(0, lc.correction_curve)
+
+    self.assertEqual(0, lc.correction_curve[0])
+
+    corrected = lc.compute_corrected_volume(0)
+    self.assertEqual(0, corrected)
+
+    corrected = lc.compute_corrected_volume(0.5)
+    self.assertEqual(1, corrected)
+
+    corrected = lc.compute_corrected_volume(1)
+    self.assertEqual(corrected, 2)
+
+    corrected = lc.compute_corrected_volume(1.5)
+    self.assertEqual(corrected, 5)
+
+    corrected = lc.compute_corrected_volume(2.5)
+    self.assertEqual(corrected, 17.5)
+
+    corrected = lc.compute_corrected_volume(9)
+    self.assertEqual(corrected, 81)
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/pyhamilton/liquid_handling/liquid_handler_tests.py b/pyhamilton/liquid_handling/liquid_handler_tests.py
new file mode 100644
index 0000000..46731ec
--- /dev/null
+++ b/pyhamilton/liquid_handling/liquid_handler_tests.py
@@ -0,0 +1,310 @@
+""" Tests for LiquidHandler """
+# pylint: disable=missing-class-docstring
+
+import io
+import os
+import tempfile
+import textwrap
+import unittest
+import unittest.mock
+
+from . import backends
+from .liquid_handler import LiquidHandler
+from .resources import (
+  Coordinate,
+  Carrier,
+  CarrierSite,
+  PlateCarrier,
+  Plate,
+  TipCarrier,
+  Tips,
+  TIP_CAR_480_A00,
+  PLT_CAR_L5AC_A00,
+  Cos_96_DW_1mL,
+  Cos_96_DW_500ul,
+  standard_volume_tip_with_filter
+)
+from .resources.ml_star import STF_L, HTF_L
+
+
+class TestLiquidHandlerLayout(unittest.TestCase):
+  def setUp(self):
+    star = backends.STAR()
+    self.lh = LiquidHandler(star)
+
+  def test_resource_assignment(self):
+    tip_car = TIP_CAR_480_A00(name="tip carrier")
+    tip_car[0] = STF_L(name="tips_01")
+    tip_car[1] = STF_L(name="tips_02")
+    tip_car[3] = HTF_L("tips_04")
+
+    plt_car = PLT_CAR_L5AC_A00(name="plate carrier")
+    plt_car[0] = Cos_96_DW_1mL(name="aspiration plate")
+    plt_car[2] = Cos_96_DW_500ul(name="dispense plate")
+
+    self.lh.assign_resource(tip_car, rails=1)
+    self.lh.assign_resource(plt_car, rails=21)
+
+    # Test placing a carrier at a location where another carrier is located.
+    with self.assertRaises(ValueError):
+      dbl_plt_car_1 = PLT_CAR_L5AC_A00(name="double placed carrier 1")
+      self.lh.assign_resource(dbl_plt_car_1, rails=1)
+
+      dbl_plt_car_2 = PLT_CAR_L5AC_A00(name="double placed carrier 2")
+      self.lh.assign_resource(dbl_plt_car_2, rails=2)
+
+      dbl_plt_car_3 = PLT_CAR_L5AC_A00(name="double placed carrier 3")
+      self.lh.assign_resource(dbl_plt_car_3, rails=20)
+
+    # Test carrier with same name.
+    with self.assertRaises(ValueError):
+      same_name_carrier = PLT_CAR_L5AC_A00(name="plate carrier")
+      self.lh.assign_resource(same_name_carrier, rails=10)
+    # Should not raise when replacing.
+    self.lh.assign_resource(same_name_carrier, rails=10, replace=True)
+    # Should not raise when unassinged.
+    self.lh.unassign_resource("plate carrier")
+    self.lh.assign_resource(same_name_carrier, rails=10, replace=True)
+
+    # Test unassigning unassigned resource
+    self.lh.unassign_resource("plate carrier")
+    with self.assertRaises(KeyError):
+      self.lh.unassign_resource("plate carrier")
+    with self.assertRaises(KeyError):
+      self.lh.unassign_resource("this resource is completely new.")
+
+    # Test invalid rails.
+    with self.assertRaises(ValueError):
+      self.lh.assign_resource(plt_car, rails=-1)
+    with self.assertRaises(ValueError):
+      self.lh.assign_resource(plt_car, rails=42)
+    with self.assertRaises(ValueError):
+      self.lh.assign_resource(plt_car, rails=27)
+
+  def test_get_resource(self):
+    tip_car = TIP_CAR_480_A00(name="tip carrier")
+    tip_car[0] = STF_L(name="tips_01")
+    plt_car = PLT_CAR_L5AC_A00(name="plate carrier")
+    plt_car[0] = Cos_96_DW_1mL(name="aspiration plate")
+    self.lh.assign_resource(tip_car, rails=1)
+    self.lh.assign_resource(plt_car, rails=10)
+
+    # Get resource.
+    self.assertEqual(self.lh.get_resource("tip carrier").name, "tip carrier")
+    self.assertEqual(self.lh.get_resource("plate carrier").name, "plate carrier")
+
+    # Get subresource.
+    self.assertEqual(self.lh.get_resource("tips_01").name, "tips_01")
+    self.assertEqual(self.lh.get_resource("aspiration plate").name, "aspiration plate")
+
+    # Get unknown resource.
+    self.assertIsNone(self.lh.get_resource("unknown resource"))
+
+  def test_subcoordinates(self):
+    tip_car = TIP_CAR_480_A00(name="tip carrier")
+    tip_car[0] = STF_L(name="tips_01")
+    tip_car[3] = HTF_L(name="tips_04")
+    plt_car = PLT_CAR_L5AC_A00(name="plate carrier")
+    plt_car[0] = Cos_96_DW_1mL(name="aspiration plate")
+    plt_car[2] = Cos_96_DW_500ul(name="dispense plate")
+    self.lh.assign_resource(tip_car, rails=1)
+    self.lh.assign_resource(plt_car, rails=10)
+
+    # Rails 10 should be left of rails 1.
+    self.assertGreater(self.lh.get_resource("plate carrier").location.x,
+                       self.lh.get_resource("tip carrier").location.x)
+
+    # Verified with Hamilton Method Editor.
+    # Carriers.
+    self.assertEqual(self.lh.get_resource("tip carrier").location,
+                     Coordinate(100.0, 63.0, 100.0))
+    self.assertEqual(self.lh.get_resource("plate carrier").location,
+                     Coordinate(302.5, 63.0, 100.0))
+
+    # Subresources.
+    self.assertEqual(self.lh.get_resource("tips_01").location,
+                     Coordinate(117.900, 145.800, 164.450))
+    self.assertEqual(self.lh.get_resource("tips_04").location,
+                     Coordinate(117.900, 433.800, 131.450))
+
+    self.assertEqual(self.lh.get_resource("dispense plate").location,
+                     Coordinate(320.500, 338.000, 188.150))
+    self.assertEqual(self.lh.get_resource("aspiration plate").location,
+                     Coordinate(320.500, 146.000, 187.150))
+
+  def test_illegal_subresource_assignment_before(self):
+    # Test assigning subresource with the same name as another resource in another carrier. This
+    # should raise an ValueError when the carrier is assigned to the liquid handler.
+    tip_car = TIP_CAR_480_A00(name="tip carrier")
+    tip_car[0] = STF_L(name="sub")
+    plt_car = PLT_CAR_L5AC_A00(name="plate carrier")
+    plt_car[0] = Cos_96_DW_1mL(name="sub")
+    self.lh.assign_resource(tip_car, rails=1)
+    with self.assertRaises(ValueError):
+      self.lh.assign_resource(plt_car, rails=10)
+
+  def test_illegal_subresource_assignment_after(self):
+    # Test assigning subresource with the same name as another resource in another carrier, after
+    # the carrier has been assigned. This should raise an error.
+    tip_car = TIP_CAR_480_A00(name="tip carrier")
+    tip_car[0] = STF_L(name="sub")
+    plt_car = PLT_CAR_L5AC_A00(name="plate carrier")
+    plt_car[0] = Cos_96_DW_1mL(name="ok")
+    self.lh.assign_resource(tip_car, rails=1)
+    self.lh.assign_resource(plt_car, rails=10)
+    with self.assertRaises(ValueError):
+      plt_car[1] = Cos_96_DW_500ul(name="sub")
+
+  def build_layout(self):
+    tip_car = TIP_CAR_480_A00(name="tip carrier")
+    tip_car[0] = STF_L(name="tips_01")
+    tip_car[1] = STF_L(name="tips_02")
+    tip_car[3] = HTF_L("tips_04")
+
+    plt_car = PLT_CAR_L5AC_A00(name="plate carrier")
+    plt_car[0] = Cos_96_DW_1mL(name="aspiration plate")
+    plt_car[2] = Cos_96_DW_500ul(name="dispense plate")
+
+    self.lh.assign_resource(tip_car, rails=1, replace=True)
+    self.lh.assign_resource(plt_car, rails=21, replace=True)
+
+  @unittest.mock.patch("sys.stdout", new_callable=io.StringIO)
+  def test_summary(self, out):
+    with self.assertRaises(ValueError):
+      self.lh.summary()
+
+    self.build_layout()
+    self.maxDiff = None # pylint: disable=invalid-name
+    expected_out = textwrap.dedent("""
+    Rail     Resource                   Type                Coordinates (mm)
+    ===============================================================================================
+    (1)  ├── tip carrier                TIP_CAR_480_A00     (100.000, 063.000, 100.000)
+         │   ├── tips_01                STF_L               (117.900, 145.800, 164.450)
+         │   ├── tips_02                STF_L               (117.900, 241.800, 164.450)
+         │   ├── <empty>
+         │   ├── tips_04                HTF_L               (117.900, 433.800, 131.450)
+         │   ├── <empty>
+         │
+    (21) ├── plate carrier              PLT_CAR_L5AC_A00    (550.000, 063.000, 100.000)
+         │   ├── aspiration plate       Cos_96_DW_1mL       (568.000, 146.000, 187.150)
+         │   ├── <empty>
+         │   ├── dispense plate         Cos_96_DW_500ul     (568.000, 338.000, 188.150)
+         │   ├── <empty>
+         │   ├── <empty>
+    """[1:])
+    self.lh.summary()
+    self.assertEqual(out.getvalue(), expected_out)
+
+  def test_parse_lay_file(self):
+    fn = "./pyhamilton/testing/test_data/test_deck.lay"
+    self.lh.load_from_lay_file(fn)
+
+    self.assertEqual(self.lh.get_resource("TIP_CAR_480_A00_0001").location, \
+                     Coordinate(122.500, 63.000, 100.000))
+    self.assertEqual(self.lh.get_resource("tips_01").location, \
+                     Coordinate(140.400, 145.800, 164.450))
+    self.assertEqual(self.lh.get_resource("STF_L_0001").location, \
+                     Coordinate(140.400, 241.800, 164.450))
+    self.assertEqual(self.lh.get_resource("tips_04").location, \
+                     Coordinate(140.400, 433.800, 131.450))
+
+    self.assertEqual(self.lh.get_resource("TIP_CAR_480_A00_0001")[0].name, "tips_01")
+    self.assertEqual(self.lh.get_resource("TIP_CAR_480_A00_0001")[1].name, "STF_L_0001")
+    self.assertIsNone(self.lh.get_resource("TIP_CAR_480_A00_0001")[2])
+    self.assertEqual(self.lh.get_resource("TIP_CAR_480_A00_0001")[3].name, "tips_04")
+    self.assertIsNone(self.lh.get_resource("TIP_CAR_480_A00_0001")[4])
+
+    self.assertEqual(self.lh.get_resource("PLT_CAR_L5AC_A00_0001").location, \
+                     Coordinate(302.500, 63.000, 100.000))
+    self.assertEqual(self.lh.get_resource("Cos_96_DW_1mL_0001").location, \
+                     Coordinate(320.500, 146.000, 187.150))
+    self.assertEqual(self.lh.get_resource("Cos_96_DW_500ul_0001").location, \
+                     Coordinate(320.500, 338.000, 188.150))
+    self.assertEqual(self.lh.get_resource("Cos_96_DW_1mL_0002").location, \
+                     Coordinate(320.500, 434.000, 187.150))
+    self.assertEqual(self.lh.get_resource("Cos_96_DW_2mL_0001").location, \
+                     Coordinate(320.500, 530.000, 187.150))
+
+    self.assertEqual(self.lh.get_resource("PLT_CAR_L5AC_A00_0001")[0].name, "Cos_96_DW_1mL_0001")
+    self.assertIsNone(self.lh.get_resource("PLT_CAR_L5AC_A00_0001")[1])
+    self.assertEqual(self.lh.get_resource("PLT_CAR_L5AC_A00_0001")[2].name, "Cos_96_DW_500ul_0001")
+    self.assertEqual(self.lh.get_resource("PLT_CAR_L5AC_A00_0001")[3].name, "Cos_96_DW_1mL_0002")
+    self.assertEqual(self.lh.get_resource("PLT_CAR_L5AC_A00_0001")[4].name, "Cos_96_DW_2mL_0001")
+
+    self.assertEqual(self.lh.get_resource("PLT_CAR_L5AC_A00_0002").location, \
+                     Coordinate(482.500, 63.000, 100.000))
+    self.assertEqual(self.lh.get_resource("Cos_96_DW_1mL_0003").location, \
+                     Coordinate(500.500, 146.000, 187.150))
+    self.assertEqual(self.lh.get_resource("Cos_96_DW_500ul_0003").location, \
+                     Coordinate(500.500, 242.000, 188.150))
+    self.assertEqual(self.lh.get_resource("Cos_96_PCR_0001").location, \
+                     Coordinate(500.500, 434.000, 186.650))
+
+    self.assertEqual(self.lh.get_resource("PLT_CAR_L5AC_A00_0002")[0].name, "Cos_96_DW_1mL_0003")
+    self.assertEqual(self.lh.get_resource("PLT_CAR_L5AC_A00_0002")[1].name, "Cos_96_DW_500ul_0003")
+    self.assertIsNone(self.lh.get_resource("PLT_CAR_L5AC_A00_0002")[2])
+    self.assertEqual(self.lh.get_resource("PLT_CAR_L5AC_A00_0002")[3].name, "Cos_96_PCR_0001")
+    self.assertIsNone(self.lh.get_resource("PLT_CAR_L5AC_A00_0002")[4])
+
+  def assert_same(self, lh1, lh2):
+    """ Assert two liquid handler decks are the same. """
+    # pylint: disable=protected-access
+    self.assertEqual(len(lh1._resources), len(lh2._resources))
+
+    for r_name in lh1._resources:
+      resource_1 = lh1.get_resource(r_name)
+      resource_2 = lh2.get_resource(r_name)
+
+      self.assertEqual(resource_1.location, resource_2.location)
+      self.assertEqual(type(resource_1), type(resource_2))
+
+      if isinstance(resource_1, Carrier):
+        self.assertEqual(len(resource_1._sites), len(resource_2._sites))
+
+        for key in range(resource_1.capacity):
+          subresource_1 = resource_1[key]
+          subresource_2        = resource_2[key]
+          self.assertEqual(type(subresource_1), type(subresource_2))
+          if subresource_2 is None:
+            self.assertIsNone(subresource_1)
+          else:
+            self.assertEqual(subresource_1.name, subresource_2.name)
+
+  def test_json_serialization(self):
+    # test with standard resource classes
+    self.build_layout()
+    tmp_dir = tempfile.gettempdir()
+    fn = os.path.join(tmp_dir, "layout.json")
+    self.lh.save(fn)
+
+    star = backends.STAR()
+    recovered = LiquidHandler(star)
+    recovered.load_from_json(fn)
+
+    self.assert_same(self.lh, recovered)
+
+    # test with custom classes
+    custom_1 = LiquidHandler(star)
+    tc = TipCarrier("tc", 200, 200, 200, [
+      CarrierSite(Coordinate(10, 20, 30), 10, 10)
+    ])
+    tc[0] = Tips("tips", 10, 20, 30, standard_volume_tip_with_filter, -1, -1, -1)
+    pc = PlateCarrier("pc", 100, 100, 100, [
+      CarrierSite(Coordinate(40, 50, 60), 10, 10)
+    ])
+    pc[0] = Plate("plate", 10, 20, 30, -1, -1, -1)
+
+    fn = os.path.join(tmp_dir, "layout.json")
+    custom_1.save(fn)
+    custom_recover = LiquidHandler(star)
+    custom_recover.load(fn)
+
+    self.assert_same(custom_1, custom_recover)
+
+    # unsupported format
+    with self.assertRaises(ValueError):
+      custom_recover.load(fn + ".unsupported")
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/pyhamilton/liquid_handling/resources/abstract/carrier_tests.py b/pyhamilton/liquid_handling/resources/abstract/carrier_tests.py
new file mode 100644
index 0000000..a8e5fdc
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/abstract/carrier_tests.py
@@ -0,0 +1,237 @@
+""" Tests for Carrier resource """
+# pylint: disable=missing-class-docstring
+
+import unittest
+
+from .tips import Tips
+from .tip_type import TipType, TIP_TYPE_STANDARD_VOLUME
+from .carrier import TipCarrier, CarrierSite
+from .coordinate import Coordinate
+
+
+class TestLiquidHandlerLayout(unittest.TestCase):
+  def setUp(self):
+    tip_type = TipType(
+      has_filter=False,
+      total_tip_length=100,
+      maximal_volume=400,
+      tip_type_id=TIP_TYPE_STANDARD_VOLUME,
+      pick_up_method=0
+    )
+
+    self.A = Tips( # pylint: disable=invalid-name
+      name="A",
+      size_x=5, size_y=5, size_z=5,
+      tip_type=tip_type,
+      dx=1, dy=1, dz=1
+    )
+
+    self.B = Tips( # pylint: disable=invalid-name
+      name="B",
+      size_x=5, size_y=5, size_z=5,
+      tip_type=tip_type,
+      dx=9, dy=2, dz=-2
+    )
+
+    self.alsoB = Tips( # pylint: disable=invalid-name
+      name="B",
+      size_x=100, size_y=100, size_z=100,
+      tip_type=tip_type,
+      dx=0, dy=0, dz=0
+    )
+
+    self.tip_car = TipCarrier(
+      "tip_car",
+      size_x=135.0, size_y=497.0, size_z=13.0,
+      sites=[
+        CarrierSite(Coordinate(10,   20, 30), 10, 10),
+        CarrierSite(Coordinate(10,   50, 30), 10, 10),
+        CarrierSite(Coordinate(10,   80, 30), 10, 10),
+        CarrierSite(Coordinate(10,  130, 30), 10, 10),
+        CarrierSite(Coordinate(10,  160, 30), 10, 10),
+      ])
+
+  def test_capacity(self):
+    self.assertEqual(self.tip_car.capacity, 5)
+
+  def test_assignment(self):
+    self.tip_car[0] = self.A
+    self.tip_car[1] = self.B
+
+  def test_get(self):
+    self.tip_car[0] = self.A
+    self.tip_car[1] = self.B
+
+    self.assertEqual(self.tip_car[0].name, "A")
+    self.assertEqual(self.tip_car[1].name, "B")
+    self.assertIsNone(self.tip_car[2])
+    self.assertIsNone(self.tip_car[3])
+    self.assertIsNone(self.tip_car[4])
+
+  def test_illegal_assignment(self):
+    with self.assertRaises(KeyError):
+      self.tip_car[-1] = self.A
+    with self.assertRaises(KeyError):
+      self.tip_car[99999] = self.A
+
+    self.tip_car[0] = self.B
+    with self.assertRaises(ValueError):
+      self.tip_car[1] = self.alsoB
+
+  def test_illegal_get(self):
+    with self.assertRaises(KeyError):
+      self.tip_car[-1] # pylint: disable=pointless-statement
+    with self.assertRaises(KeyError):
+      self.tip_car[99999] # pylint: disable=pointless-statement
+
+  def test_over_assignment(self):
+    with self.assertLogs() as captured:
+      self.tip_car[0] = self.A
+      self.tip_car[0] = self.B
+    self.assertIn("Overriding", captured.records[0].getMessage())
+
+  def test_location(self):
+    self.tip_car[0] = self.A
+    self.assertEqual(self.tip_car[0].location, Coordinate(11, 21, 31))
+
+    self.tip_car[1] = self.B
+    self.assertEqual(self.tip_car[1].location, Coordinate(19, 52, 28))
+
+  def test_serialization(self):
+    self.maxDiff = None # pylint: disable=invalid-name
+    self.assertEqual(self.tip_car.serialize(), {
+      "category": "tip_carrier",
+      "location": {"x": None, "y": None, "z": None},
+      "name": "tip_car",
+      "sites": [
+        {
+          "site": {
+            "location": {"x": 10, "y": 20, "z": 30},
+            "resource": None,
+            "width": 10,
+            "height": 10
+          },
+          "site_id": 0
+        },
+        {
+          "site": {
+            "location": {"x": 10, "y": 50, "z": 30},
+            "resource": None,
+            "width": 10,
+            "height": 10
+          },
+          "site_id": 1
+        },
+        {
+          "site": {
+            "location": {"x": 10, "y": 80, "z": 30},
+            "resource": None,
+            "width": 10,
+            "height": 10
+          },
+          "site_id": 2
+        },
+        {
+          "site": {
+            "location": {"x": 10, "y": 130, "z": 30},
+            "resource": None,
+            "width": 10,
+            "height": 10
+          },
+          "site_id": 3
+        },
+        {
+          "site": {
+            "location": {"x": 10, "y": 160, "z": 30},
+            "resource": None,
+            "width": 10,
+            "height": 10
+          },
+          "site_id": 4
+        }
+      ],
+      "size_x": 135.0,
+      "size_y": 497.0,
+      "size_z": 13.0,
+      "type": "TipCarrier"
+    })
+
+    self.tip_car[1] = self.B
+    self.assertEqual(self.tip_car.serialize(), {
+      "category": "tip_carrier",
+      "location": {"x": None, "y": None, "z": None},
+      "name": "tip_car",
+      "sites": [
+        {
+          "site": {
+            "location": {"x": 10, "y": 20, "z": 30},
+            "resource": None,
+            "width": 10,
+            "height": 10,
+          },
+          "site_id": 0
+        },
+        {
+          "site": {
+            "location": {"x": 10, "y": 50, "z": 30},
+            "resource": {
+              "category": "tips",
+              "dx": 9,
+              "dy": 2,
+              "dz": -2,
+              "location": {"x": 19, "y": 52, "z": 28},
+              "name": "B",
+              "size_x": 5,
+              "size_y": 5,
+              "size_z": 5,
+              "tip_type": {
+                "has_filter": False,
+                "maximal_volume": 400,
+                "pick_up_method": 0,
+                "tip_type_id": 2,
+                "total_tip_length": 100
+              },
+              "type": "Tips"
+            },
+            "width": 10,
+            "height": 10
+          },
+          "site_id": 1
+        },
+        {
+          "site": {
+            "location": {"x": 10, "y": 80, "z": 30},
+            "resource": None,
+            "width": 10,
+            "height": 10,
+          },
+          "site_id": 2
+        },
+        {
+          "site": {
+            "location": {"x": 10, "y": 130, "z": 30},
+            "resource": None,
+            "width": 10,
+            "height": 10,
+          },
+          "site_id": 3
+        },
+        {
+          "site": {
+            "location": {"x": 10, "y": 160, "z": 30},
+            "resource": None,
+            "width": 10,
+            "height": 10,
+          },
+          "site_id": 4
+        }
+      ],
+      "size_x": 135.0,
+      "size_y": 497.0,
+      "size_z": 13.0,
+      "type": "TipCarrier"
+    })
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/pyhamilton/liquid_handling/resources/abstract/coordinate_tests.py b/pyhamilton/liquid_handling/resources/abstract/coordinate_tests.py
new file mode 100644
index 0000000..17d135e
--- /dev/null
+++ b/pyhamilton/liquid_handling/resources/abstract/coordinate_tests.py
@@ -0,0 +1,38 @@
+""" Tests for Coordinate """
+# pylint: disable=missing-class-docstring
+
+import unittest
+
+from .coordinate import Coordinate
+
+
+class TestCoordinate(unittest.TestCase):
+  def setUp(self):
+    self.a = Coordinate(1, 2, 3)
+    self.b = Coordinate(10, 10, 10)
+    self.c = Coordinate(0, 0, 0)
+    self.d = Coordinate(None, None, None)
+
+  def test_addition(self):
+    self.assertEqual(self.a, self.a)
+    self.assertEqual(self.a + self.c, self.a)
+    self.assertEqual(self.a + self.b, Coordinate(11, 12, 13))
+    self.assertEqual(self.b + self.b, Coordinate(20, 20, 20))
+
+  def test_none_coordinates(self):
+    self.assertEqual(self.d, self.d)
+    self.assertEqual(self.a + self.d, self.a)
+    self.assertEqual(self.d + self.a, self.a)
+    self.assertEqual(self.a - self.d, self.a)
+
+  def test_to_string(self):
+    self.assertEqual(f"{self.a}", "(001.000, 002.000, 003.000)")
+    self.assertEqual(f"{self.d}", "(None, None, None)")
+
+  def test_serialization(self):
+    self.assertEqual(self.a.serialize(), dict(x=1, y=2, z=3))
+    self.assertEqual(self.a, Coordinate.deserialize(self.a.serialize()))
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/pyhamilton/testing/test_data/test_deck.lay b/pyhamilton/testing/test_data/test_deck.lay
new file mode 100644
index 0000000..d04b1c2
Binary files /dev/null and b/pyhamilton/testing/test_data/test_deck.lay differ
diff --git a/pyhamilton/utils/positions_tests.py b/pyhamilton/utils/positions_tests.py
new file mode 100644
index 0000000..f8e76b8
--- /dev/null
+++ b/pyhamilton/utils/positions_tests.py
@@ -0,0 +1,30 @@
+""" Tests for positions"""
+
+import unittest
+
+from pyhamilton.utils import string_to_position, string_to_pattern
+
+
+class TestLiquidHandlerNew(unittest.TestCase):
+  def setUp(self) -> None:
+    super().setUp()
+    self.maxDiff = None
+
+  def test_string_to_pattern(self):
+    # pylint: disable=protected-access
+    self.assertEqual(string_to_position("A1"), (0, 0))
+    self.assertEqual(string_to_position("A3"), (0, 2))
+    self.assertEqual(string_to_position("C1"), (2, 0))
+
+  def test_string_range_to_pattern(self):
+    # pylint: disable=protected-access
+    self.assertEqual(string_to_pattern("A1:C3"),
+      [[True]*3 + [False]*9]*3 + [[False]*12]*5)
+    self.assertEqual(string_to_pattern("A1:A3"),
+      [[True]*3 + [False]*9] + [[False]*12]*7)
+    self.assertEqual(string_to_pattern("A1:C1"),
+      [[True] + [False] * 11]*3 + [[False]*12]*5)
+
+
+if __name__ == "__main__":
+  unittest.main()
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..db60836
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+python_files = *_tests.py
+