Repository: qutech/qupulse (language: python)

Issue:
compare hdawg_linspace hacky changes to main
not commented/documented


Discussion / hints:

Fix patch:
diff --git a/README.md b/README.md
index 38509803a..d47d1e09f 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ python -m pip install -e git+https://github.com/qutech/qupulse.git#egg=qupulse[d
 which will clone the github repository to `./src/qupulse` and do an editable/development install. 
 
 ### Requirements and dependencies
-qupulse requires at least Python 3.7 and is tested on 3.7, 3.8 and 3.9. It relies on some external Python packages as dependencies. 
+qupulse requires at least Python 3.8 and is tested on 3.8, 3.9 and 3.10. It relies on some external Python packages as dependencies. 
 We intentionally did not restrict versions of dependencies in the install scripts to not unnecessarily prevent usage of newer releases of dependencies that might be compatible. However, if qupulse does encounter problems with a particular dependency version please file an issue. 
 
 The backend for TaborAWGs requires packages that can be found [here](https://git.rwth-aachen.de/qutech/python-TaborDriver). As a shortcut you can install it from the python interpreter via `qupulse.hardware.awgs.install_requirements('tabor')`.
diff --git a/RELEASE_NOTES.rst b/RELEASE_NOTES.rst
index c61c8b8c0..c76c7e9c9 100644
--- a/RELEASE_NOTES.rst
+++ b/RELEASE_NOTES.rst
@@ -2,6 +2,69 @@
 
 .. towncrier release notes start
 
+qupulse 0.10 (2024-04-04)
+=========================
+
+Features
+--------
+
+- Move HDAWG driver to qupulse-hdawg-legacy to disentangle driver version from qupulse version. The new HDAWG driver will be published under qupulse-hdawg. (`#779 <https://github.com/qutech/qupulse/issues/779>`_)
+- Add the `ProgramBuilder` interface pattern to make the generated program of `PulseTemplate.create_program` easily customizable. (`#781 <https://github.com/qutech/qupulse/issues/781>`_)
+- Measurement windows can now automatically shrank in case of overlap to counteract small numeric errors. (`#791 <https://github.com/qutech/qupulse/issues/791>`_)
+
+
+Bugfixes
+--------
+
+- ``ConstantPulseTemplate``s from all versions can now be deserialized. (`#696 <https://github.com/qutech/qupulse/issues/696>`_)
+- Fixed that single segment tables where always interpreted to be constant. (`#707 <https://github.com/qutech/qupulse/issues/707>`_)
+- Add missing pulse registry support to `ArithmeticPT`. (`#775 <https://github.com/qutech/qupulse/issues/775>`_)
+
+
+qupulse 0.9 (2023-11-08)
+========================
+
+Features
+--------
+
+- Add `__pow__` as a repetition shortcut. This means you can do `my_pulse_template ** 5` or `my_pulse_template ** 'my_repetition_count'`. (`#692 <https://github.com/qutech/qupulse/issues/692>`_)
+- Promote ``qupulse.expression`` to a subpackage and create ``qupulse.expression.protocol`` with protocol classes that define the expression interface that is supposed to be used by qupulse.
+  The ```sympy`` based implementation is moved to ``qupulse.expressions.sympy`` and imported in ``qupulse.expressions``.
+
+  The intended use is to be able to use less powerful but faster implementations of the ``Expression`` protocol where appropriate.
+  In this first iteration, qupulse still relies on internals of the ``sympy`` based implementation in many places which is to be removed in the future. (`#750 <https://github.com/qutech/qupulse/issues/750>`_)
+- Promote parts of the private subpackage `qupulse._program` to the public subpackage `qupulse.program`, i.e. `loop`, `volatile`, `transformation` and `waveforms`. This allows external packages/drivers to rely on stability of the `Loop` class. (`#779 <https://github.com/qutech/qupulse/issues/779>`_)
+- Add ``PulseTemplate.pad_to`` method to help padding to minimal lengths or multiples of given durations. (`#801 <https://github.com/qutech/qupulse/issues/801>`_)
+
+
+Misc
+----
+
+- `#771 <https://github.com/qutech/qupulse/issues/771>`_
+
+
+qupulse 0.8 (2023-03-28)
+========================
+
+Features
+--------
+
+- New two dimensional plotting function ``qupulse.pulses.plotting.plot_2d``. (`#703 <https://github.com/qutech/qupulse/issues/703>`_)
+- Add support for time dependent expressions for arithmetics with atomic pulse templates i.e. ``ParallelChannelPT`` and
+  ``ArithmeticPT`` support time dependent expressions if used with atomic pulse templates.
+  Rename ``ParallelConstantChannelPT`` to ``ParallelChannelPT`` to reflect this change. (`#709 <https://github.com/qutech/qupulse/issues/709>`_)
+- Add ``with_`` family of helper methods to ``PulseTemplate`` to allow convinient and easily discoverable pulse template
+  combination. (`#710 <https://github.com/qutech/qupulse/issues/710>`_)
+- The plotting module is now located at `qupulse.plotting`. There is a legacy alias at `qupulse.pulses.plotting`. (`#735 <https://github.com/qutech/qupulse/issues/735>`_)
+
+
+Deprecations and Removals
+-------------------------
+
+- Remove the ``Parameter``, ``MappedParameter`` and ``ConstantParameter`` classes that where deprecated in version 0.5. (`#512 <https://github.com/qutech/qupulse/issues/512>`_)
+- Drop support for python version 3.7. (`#760 <https://github.com/qutech/qupulse/issues/760>`_)
+
+
 qupulse 0.7 (2022-10-05)
 ========================
 
diff --git a/changes.d/512.removal b/changes.d/512.removal
deleted file mode 100644
index 83a9d441b..000000000
--- a/changes.d/512.removal
+++ /dev/null
@@ -1,1 +0,0 @@
-Remove the `Parameter`, `MappedParameter` and `ConstantParameter` class that where deprecated in version 0.5.
diff --git a/changes.d/696.fix b/changes.d/696.fix
deleted file mode 100644
index 9df69bd0b..000000000
--- a/changes.d/696.fix
+++ /dev/null
@@ -1,1 +0,0 @@
-`ConstantPulseTemplate`s from all versions can now be deserialized.
\ No newline at end of file
diff --git a/changes.d/703.feature b/changes.d/703.feature
deleted file mode 100644
index af8aeb0b4..000000000
--- a/changes.d/703.feature
+++ /dev/null
@@ -1,1 +0,0 @@
-New two dimensional plotting function ``qupulse.pulses.plotting.plot_2d``.
diff --git a/changes.d/707.fix b/changes.d/707.fix
deleted file mode 100644
index 33e9e9f8e..000000000
--- a/changes.d/707.fix
+++ /dev/null
@@ -1,1 +0,0 @@
-Fixed that single segment tables where always interpreted to be constant.
diff --git a/changes.d/709.feature b/changes.d/709.feature
deleted file mode 100644
index 9bdf45b12..000000000
--- a/changes.d/709.feature
+++ /dev/null
@@ -1,3 +0,0 @@
-Add support for time dependent expressions for arithmetics with atomic pulse templates i.e. ParallelChannelPT and
-ArithmeticPT support time dependent expressions if used with atomic pulse templates.
-Rename `ParallelConstantChannelPT` to `ParallelChannelPT` to reflect this change.
diff --git a/changes.d/710.feature b/changes.d/710.feature
deleted file mode 100644
index 650482cc2..000000000
--- a/changes.d/710.feature
+++ /dev/null
@@ -1,2 +0,0 @@
-Add `with_` family of helper methods to `PulseTemplate` to allow convinient and easily discoverable pulse template
-combination.
diff --git a/changes.d/735.feature b/changes.d/735.feature
deleted file mode 100644
index 827aca302..000000000
--- a/changes.d/735.feature
+++ /dev/null
@@ -1,1 +0,0 @@
-The plotting module is now located at `qupulse.plotting`. There is a legacy alias at `qupulse.pulses.plotting`.
diff --git a/changes.d/808.doc b/changes.d/808.doc
new file mode 100644
index 000000000..a27837ceb
--- /dev/null
+++ b/changes.d/808.doc
@@ -0,0 +1,1 @@
+Add an example with a Zurich Instruments HDAWG and MFLI.
\ No newline at end of file
diff --git a/doc/source/concepts/program.rst b/doc/source/concepts/program.rst
index 0b34df0ae..82208ac15 100644
--- a/doc/source/concepts/program.rst
+++ b/doc/source/concepts/program.rst
@@ -4,14 +4,25 @@ Instantiated Pulse: Program
 ---------------------------
 
 In qupulse an instantiated pulse template is called a program as it is something that an arbitrary waveform generator
-(AWG) can execute/playback. It is created by the `create_program` method of the pulse template which returns a hardware
-independent representation which is currently of type ``Loop``. Opposed to the `PulseTemplate` interfaces the interface of the program is currently not covered by the qupulse backward compatibility and stability guarantee.
-This is reflected by the fact that it lives in the private module ``qupulse._program._loop``.
+(AWG) can execute/playback.
+It is created by the ``create_program`` method of the pulse template which returns a hardware
+independent representation which is of type ``Loop`` by default. The method takes a ``program_builder`` keyword argument
+which is passed through the pulse template tree and thereby implements the visitor pattern. If the argument is not
+passed ``default_program_builder()`` is used instead which is ``LoopBuilder`` by default.
 
-There is no description of the details of the program object here to avoid outdated documentation.
+The ``Loop`` default program is the root node of a tree ``Loop``s of arbitrary depth.
+Each node consists of a repetition count and either a waveform or a sequence of nodes which are repeated that many times.
+Iterations like the ```ForLoopPT`` cannot be represented natively but are unrolled into a sequence of items.
+The repetition count is currently the only property of a program that can be defined as volatile. This means that the AWG driver tries to upload the program in a way, where the repetition count can quickly be changed. This is implemented via the ```VolatileRepetitionCount`` class.
+
+There is no description of the details of the program object here to avoid duplicated and outdated documentation.
 The documentation is in the docstrings of the source code.
 The program can be thought of as compact representation of a mapping :math:`\{t | 0 \le t \le t_{\texttt{duration}}} \rightarrow \mathbb{R}^n` from the time while the program lasts :math:´t´ to an n-dimensional voltage space :math:´\mathbb{R}^n´.
 The dimensions are named by the channel names.
 
-The subpackage ``_qupulse._program`` also contains hardware specific translations of the programs for example a
-transpiler to Zurich Instruments sequencing C in ``_qupulse._program.seqc``.
+The ``Loop`` class and its constituents ``Waveform`` and ``VolatileRepetitionCount`` are defined in the ``qupulse.program`` subpackage and it's submodules.
+The private subpackage ``qupulse._program`` contains AWG driver internals that can change with any release, for example a
+transpiler to Zurich Instruments sequencing C in ``qupulse._program.seqc``.
+
+Another program format that is currently under development is ``LinSpaceProgram`` which efficiently encodes linearly
+spaced sweeps in voltage space. However, the status of this is preliminary and not yet documented here.
diff --git a/doc/source/concepts/pulsetemplates.rst b/doc/source/concepts/pulsetemplates.rst
index 9553f0e3f..9c1463917 100644
--- a/doc/source/concepts/pulsetemplates.rst
+++ b/doc/source/concepts/pulsetemplates.rst
@@ -33,7 +33,7 @@ Parameters
 
 As mentioned above, all pulse templates may depend on parameters. During pulse template initialization the parameters simply are the free variables of expressions that occur in the pulse template. For example the :class:`.FunctionPulseTemplate` has expressions for its duration and the voltage time dependency i.e. the underlying function. Some pulse templates provided means to constrain parameters by accepting a list of :class:`.ParameterConstraint` which encapsulate comparative expressions that must evaluate to true for a given parameter set to successfully instantiate a pulse from the pulse template. This can be used to encode physical or logical parameter boundaries at pulse level.
 
-The mathematical expressions (for parameter transformation or as the function of the :class:`.FunctionPulseTemplate`) are encapsulated into an :class:`.Expression` class which wraps `sympy <http://www.sympy.org/en/index.html>`_ for string evaluation.
+The mathematical expressions (for parameter transformation or as the function of the :class:`.FunctionPulseTemplate`) are encapsulated into an :class:`.sympy.Expression` class which wraps `sympy <http://www.sympy.org/en/index.html>`_ for string evaluation by default. Other more performant or secure backends can potentially be implemented by conforming to the :class:`.protocol.Expression`.
 
 Parameters can be mapped to arbitrary expressions via :class:`.mapping_pulse_template.MappingPulseTemplate`. One use case can be deriving pulse parameters from physical quantities.
 
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 5c25f321c..1a90f416d 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -81,7 +81,7 @@
 
 # General information about the project.
 project = 'qupulse'
-copyright = '2015-2022, Quantum Technology Group, RWTH Aachen University'
+copyright = '2015-2024, Quantum Technology Group, RWTH Aachen University'
 author = 'Quantum Technology Group and Chair of Software Engineering, RWTH Aachen University'
 
 # The version info for the project you're documenting, acts as replacement for
diff --git a/doc/source/examples/04ZurichInstrumentsSetup.ipynb b/doc/source/examples/04ZurichInstrumentsSetup.ipynb
new file mode 100644
index 000000000..851c806bc
--- /dev/null
+++ b/doc/source/examples/04ZurichInstrumentsSetup.ipynb
@@ -0,0 +1,485 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "7e31fbc9f47a77ce",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "# Zurich Instruments Hardware Setup\n",
+    "\n",
+    "This notebook shows an exemplary use of qupulse with a ZI HDAWG and MFLI. The drivers for these instruments are kept in external packages to facilitate easy driver customization. Depending on your python version and hardware version you either need `qupulse-hdawg-legacy` or `qupulse-hdawg` for the HDAWG and `qupulse-mfli` for the MFLI.\n",
+    "\n",
+    "## Connections and wiring\n",
+    "\n",
+    "The example here assumes a very nonsensical wiring that does not require anything else besides an HDAWG, and MFLI and three cables/adapters to connect SMB to BNC ports. We assume the following connections:\n",
+    "\n",
+    "```\n",
+    "HDAWG_1_WAVE -> MFLI_AUX_IN_1\n",
+    "HDAWG_2_WAVE -> MFLI_AUX_IN_2\n",
+    "HDAWG_1_MARK_FRONT -> MFLI_TRIG_IN_1\n",
+    "```\n",
+    "`MFLI_TRIG_IN_1` is located on the back of the device.\n",
+    "\n",
+    "## Hardware Setup\n",
+    "\n",
+    "The hardware setup class provides a layer to map output channels to an arbitrary number of physical channels.\n",
+    "It also provides a mapping of measurement windows to specific dac instruments."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "id": "6432f1ccf75c7d58",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "from qupulse.hardware.setup import HardwareSetup\n",
+    "\n",
+    "hw_setup = HardwareSetup()"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "code",
+   "id": "initial_id",
+   "metadata": {
+    "collapsed": true
+   },
+   "source": [
+    "# This abstracts over possibly installed hdawg drivers\n",
+    "from qupulse.hardware.awgs.zihdawg import HDAWGRepresentation\n",
+    "\n",
+    "awg_serial = 'DEVXXXX'\n",
+    "assert awg_serial != 'DEVXXXX', \"Please enter the serial of a connected HDAWG\"\n",
+    "\n",
+    "hdawg = HDAWGRepresentation(awg_serial, 'USB' )"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "markdown",
+   "id": "4f15ba19d0961dbb",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "### Channel groupings\n",
+    "\n",
+    "The `AWG` class abstracts over a set of dependently programmable channels. The HDAWG supports multiple channel groupings which decouples individual channel groups. The most robust setting for qupulse is to use the `1x8` channel grouping which executes the same sequencing program on all channels and only differs in the waveform data that is sequenced. This results in a single channel tuple/`AWG` object which represents all eight channels.\n",
+    "\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "id": "eb9a838c161c244d",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "from qupulse.hardware.awgs.zihdawg import HDAWGChannelGrouping\n",
+    "from qupulse.hardware.setup import PlaybackChannel, MarkerChannel\n",
+    "\n",
+    "hdawg.channel_grouping = HDAWGChannelGrouping.CHAN_GROUP_1x8\n",
+    "awg, = hdawg.channel_tuples\n",
+    "\n",
+    "# here we assume plunger one and two are connected to the two first channels of the AWG\n",
+    "# It is considered best practice to use such names that relate to the connected sample gates\n",
+    "hw_setup.set_channel('P1', PlaybackChannel(awg, 0))\n",
+    "hw_setup.set_channel('P2', PlaybackChannel(awg, 1))\n",
+    "\n",
+    "# We connect the trigger to the marker output of the first channel\n",
+    "hw_setup.set_channel('Trig', MarkerChannel(awg, 0))\n",
+    "\n",
+    "# We can assign the same channel to multiple identifiers. Here we just assign all channels to a hardware name\n",
+    "for channel_idx, channel_letter in enumerate('ABCDEFGH'):\n",
+    "    channel_name = f\"{hdawg.serial}_{channel_letter}\"\n",
+    "    hw_setup.set_channel(channel_name, PlaybackChannel(awg, channel_idx), allow_multiple_registration=True)\n",
+    "\n",
+    "# We can also assign multiple channels to the same identifier\n",
+    "hw_setup.set_channel(f\"{hdawg.serial}_ALL\", [PlaybackChannel(awg, idx) for idx in range(8)], allow_multiple_registration=True)"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "markdown",
+   "id": "10ada657dd098fc7",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "### MFLI\n",
+    "\n",
+    "Next we will connect the MFLI."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "id": "99e3edfcdf4ff697",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "from qupulse_mfli.mfli import MFLIDAQ, postprocessing_average_within_windows\n",
+    "\n",
+    "mfli_serial = 'DEVXXXX'\n",
+    "assert mfli_serial != 'DEVXXXX', \"Please enter the serial of a connected MFLI\"\n",
+    "\n",
+    "mfli = MFLIDAQ.connect_to(mfli_serial)"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "markdown",
+   "id": "bf2a9ed85290c479",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "### Measurement masks\n",
+    "\n",
+    "qupulse has multiple layers where measurements are mapped. The hardware setup can map measurement windows to potentially multiple measurement masks, which are a combination of an instrument and an instrument specific identifier."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "id": "6afb0d40c704a02",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "from qupulse.hardware.setup import MeasurementMask\n",
+    "\n",
+    "hw_setup.set_measurement('SET1', MeasurementMask(mfli, 'AverageAux1'))\n",
+    "hw_setup.set_measurement('SET2', MeasurementMask(mfli, 'AverageAux2'))\n",
+    "hw_setup.set_measurement('SET_ALL', [MeasurementMask(mfli, 'AverageAux1'), MeasurementMask(mfli, 'AverageAux2')], allow_multiple_registration=True)\n"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "Each instrument can do arbitrary things with the identifier from the mask which heavily depends on what the instrument can do and what you use it for.\n",
+    "\n",
+    "The MLFI maps the names to internal paths following your configuration. You can make the configuration global or program specific."
+   ],
+   "id": "e7353faf52ebd31d"
+  },
+  {
+   "cell_type": "code",
+   "id": "c943fb4d",
+   "metadata": {},
+   "source": [
+    "# linking the measurement mask names to physical input channels\n",
+    "mfli.register_measurement_channel(program_name=None, channel_path=\"demods/0/sample.AuxIn0\", window_name=\"AverageAux2\")\n",
+    "mfli.register_measurement_channel(program_name=None, channel_path=\"auxins/0/sample.AuxIn1\", window_name=\"AverageAux1\")"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "The other inputs can be addressed via strings as the following:\n",
+    "```\n",
+    "{\n",
+    "    \"R\": [\"demods/0/sample.R\"],\n",
+    "    \"X\": [\"demods/0/sample.X\"],\n",
+    "    \"Y\": [\"demods/0/sample.Y\"],\n",
+    "    \"A\": [\"auxins/0/sample.AuxIn0.avg\"],\n",
+    "    \"many\": [\"demods/0/sample.R\", \"auxins/0/sample.AuxIn0.avg\", \"demods/0/sample.X\", \"demods/0/sample.Y\"]\n",
+    "}\n",
+    "```\n",
+    "where the keys of the dict are the values for the window_name, and the values of the dict are the channel_path inputs. Note that these can also be lists to record multiple channels under one name. I.e. for IQ demodulation.\n",
+    "\n",
+    "### Operations\n",
+    "\n",
+    "Each driver can automatically perform certain operations on the recorded data. The MFLI expects a callable that processes the raw data returned by the instrument. This is suboptimal but the current solution. If you want to implement your own operation look at the shipped postprocessing functions for the signature.\n",
+    "\n",
+    "There are other functions you can use defined in the mfli package like `postprocessing_crop_windows`. Please file an issue if this documentation here is out of date."
+   ],
+   "id": "9bb6bf76c80276e9"
+  },
+  {
+   "cell_type": "code",
+   "id": "51401e51",
+   "metadata": {},
+   "source": [
+    "# configuring the driver to average all datapoint for each window.\n",
+    "mfli.register_operations(\n",
+    "    program_name=None,\n",
+    "    operations=postprocessing_average_within_windows\n",
+    ")"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": "",
+   "id": "ee6314ed25574b8f"
+  },
+  {
+   "cell_type": "code",
+   "id": "417c4976",
+   "metadata": {},
+   "source": [
+    "# registering trigger settings for a standard configuration\n",
+    "# The measurement is perfomed once after one trigger on TrigIn1 is observed.\n",
+    "mfli.register_trigger_settings(program_name=None,\n",
+    "                                   trigger_input=f\"demods/0/sample.TrigIn1\", # here TrigInN referrers to the printer label N\n",
+    "                                   edge=\"rising\",\n",
+    "                                   trigger_count=1,\n",
+    "                                   level=.5,\n",
+    "                                   measurement_count=1,\n",
+    "                                   other_settings={\"holdoff/time\": 1e-3}\n",
+    "                                   ) "
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "markdown",
+   "id": "a7c5b77d781b5ab2",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "## Pulse definition\n",
+    "\n",
+    "Next we define a pulse that we want to use. We settle for a two-dimensional scan of a voltage space but we define the scan in terms of virtual gates, i.e. the potentials that the quantum dots `Q1` and `Q2` see.\n",
+    "Then we provide a linear transformation that maps them to the output voltages `P1` and `P2`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "id": "27610ca4eb6cda25",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "from qupulse.pulses import *\n",
+    "import numpy as np\n",
+    "from qupulse.program.transformation import LinearTransformation\n",
+    "from qupulse.program.loop import Loop, LoopBuilder, roll_constant_waveforms\n",
+    "\n",
+    "awg_sample_rate = 10**9\n",
+    "hdawg.set_sample_clock(awg_sample_rate)\n",
+    "\n",
+    "pt = (ConstantPT(2**20, {\n",
+    "    'Q1': '-0.1 + x_i * 0.02',\n",
+    "    'Q2': '-0.2 + y_i * 0.01'}, measurements=[('meas', 0, 2**20)])\n",
+    "      .with_iteration('x_i', 'N_x')\n",
+    "      .with_iteration('y_i', 'N_y')\n",
+    "      .with_parallel_channels({'Marker': 1}))\n",
+    "\n",
+    "trafo = LinearTransformation(np.array([[1., -.1], [-.09, 1.]])*0.5,\n",
+    "                             ('Q1', 'Q2'),\n",
+    "                             ('P1', 'P2'))\n",
+    "\n",
+    "measurement_mapping = {'meas': 'SET_ALL'}\n",
+    "\n",
+    "# we chose the default LoopBuilder program builder here as it is the only supported as the time of writing this example \n",
+    "program: Loop = pt.create_program(parameters={'N_x': 20, 'N_y': 30},\n",
+    "                                  global_transformation=trafo,\n",
+    "                                  program_builder=LoopBuilder(),\n",
+    "                                  measurement_mapping=measurement_mapping,\n",
+    "                                  channel_mapping={'Marker': 'Trig'})"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": [
+    "## HDAWG: Waveform compression and sample rate reduction\n",
+    "\n",
+    "The HDAWG has the capability to dynamically reduce the sample rate by a power of two during playback. The driver does this automatically if it detects a compatible waveform that is (piecewise) constant.\n",
+    "\n",
+    "However, the current implementation samples all waveforms in the computer memory. We have a lot (N_x * N_y) of very long waveforms which each take 4 MB in computer memory when sampled with 1GHz. For a sufficiently high resolution this will eat up our RAM with constant waveforms. qupulse provides `roll_constant_waveforms` to detect long constant waveforms and roll them into loops **inplace** if possible with the given parameters. This will remove the measurements from the `Loop` program because they cannot be preserved by the logic. Therefore, we extract them beforehand."
+   ],
+   "id": "cb014734179113dd"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "# extract measurement positions\n",
+    "measurements = program.get_measurement_windows(drop=True)\n",
+    "\n",
+    "print(f'Single point before rolling: {program[0]!r}')\n",
+    "\n",
+    "# Compress program\n",
+    "roll_constant_waveforms(program, sample_rate=awg_sample_rate / 10**9, waveform_quantum=256, minimal_waveform_quanta=16)\n",
+    "\n",
+    "print(f'Single point after rolling: {program[0]!r}')"
+   ],
+   "id": "435420307d15bfd2",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "code",
+   "id": "346dabd84ea976fd",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "hw_setup.clear_programs()\n",
+    "hw_setup.register_program('csd', program, awg.run_current_program, measurements=measurements)"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "hdawg.output(1, True)\n",
+    "hdawg.output(2, True)"
+   ],
+   "id": "5d7d63d18bd6fc25",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "code",
+   "id": "832c501c9082dc5d",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "hw_setup.arm_program('csd')"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "code",
+   "id": "954f4e09664d073f",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "hw_setup.run_program('csd')\n",
+    "import time; time.sleep(float(program.duration) / 1e9)"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "hdawg.output(1, False)\n",
+    "hdawg.output(2, False)"
+   ],
+   "id": "f4537c8c741597b0",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e517992b6a35fc07",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "The data extration is not standardized at the time of writing this example because it heavily depends on your data processing pipeline how the data is handled and where it shall go. qupulse has no functionality to associate a measured value with the value of some parameter that might have been varied during the measurement."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "id": "636ea90347e59fe5",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "# receaving the recorded data from the MFLI\n",
+    "\n",
+    "data = mfli.measure_program(wait=True) # wait=True would wait until the aquisition is finished.\n"
+   ],
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "cell_type": "markdown",
+   "id": "90137bb3a5dd2f22",
+   "metadata": {
+    "collapsed": false
+   },
+   "source": [
+    "The recorded data is sliced to the measurement windows in the default configuration. Thus ```my_lockin.measure_program``` returns a list (number of measurements) of dicts (the qupulse channels), of dicts (the lockin channels), of lists (the observed trigger), of lists of xarray DataArrays (each DataArray containing the data sliced for one window) or numpy arrays (containing the data resulting from averaging over the windows). I.e. ```returned_data[<i_measurement>][<qupulse channel>][<lockin channel>][<i_triggerevent>]``` leads to ether the list of DataArrays or to a numpy array."
+   ]
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "data_0 = data[0]\n",
+    "(average_1,), = data_0['AverageAux1'].values()\n",
+    "(average_2,), = data_0['AverageAux2'].values()"
+   ],
+   "id": "d3aa849c80214139",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "markdown",
+   "source": "Warning: As the time of writing this example there are problems when no demodulator is used at all. One channel looks like it has a sliding window average. Contribution in fixing that is highly appreciated.",
+   "id": "fee1a294623c50e8"
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": [
+    "import matplotlib.pyplot as plt\n",
+    "\n",
+    "plt.plot(average_1, '*')"
+   ],
+   "id": "bc7de11e789884f2",
+   "outputs": [],
+   "execution_count": null
+  },
+  {
+   "metadata": {},
+   "cell_type": "code",
+   "source": "plt.plot(average_2)",
+   "id": "14c5cd608d2238a3",
+   "outputs": [],
+   "execution_count": null
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 2
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython2",
+   "version": "2.7.6"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/doc/source/learners_guide.rst b/doc/source/learners_guide.rst
index 7ddce71a4..ee728dd2a 100644
--- a/doc/source/learners_guide.rst
+++ b/doc/source/learners_guide.rst
@@ -91,7 +91,7 @@ This section is incomplete.
 
 **Learning Task 1:**
 
-Read :ref:`program` and :ref:`awgs`.
+Read :ref:`program` and :ref:`hardware`.
 
 
 Setup an experiment
diff --git a/qupulse/__init__.py b/qupulse/__init__.py
index 6cae787b1..5bf9d2ffe 100644
--- a/qupulse/__init__.py
+++ b/qupulse/__init__.py
@@ -2,7 +2,7 @@
 
 import lazy_loader as lazy
 
-__version__ = '0.7'
+__version__ = '0.10'
 
 __getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
 
diff --git a/qupulse/__init__.pyi b/qupulse/__init__.pyi
index d61a81d6b..8c311a9e3 100644
--- a/qupulse/__init__.pyi
+++ b/qupulse/__init__.pyi
@@ -3,6 +3,7 @@ from . import pulses
 from . import hardware
 from . import utils
 from . import _program
+from . import program
 
 from . import comparable
 from . import expressions
@@ -16,6 +17,7 @@ __all__ = ['pulses',
            'hardware',
            'utils',
            '_program',
+           'program',
            'comparable',
            'expressions',
            'parameter_scope',
diff --git a/qupulse/_program/_loop.py b/qupulse/_program/_loop.py
index 92d3f7eb2..b5ad53deb 100644
--- a/qupulse/_program/_loop.py
+++ b/qupulse/_program/_loop.py
@@ -1,685 +1,9 @@
-from typing import Union, Dict, Iterable, Tuple, cast, List, Optional, Generator, Mapping
-from collections import defaultdict
-from enum import Enum
-import warnings
-import bisect
+"""Backwards compatibility link to qupulse.program.loop"""
 
-import numpy as np
-import sympy.ntheory
+from qupulse.program.loop import *
 
-from qupulse._program.waveforms import Waveform, ConstantWaveform
-from qupulse._program.volatile import VolatileRepetitionCount, VolatileProperty
+import qupulse.program.loop
 
-from qupulse.utils import is_integer
-from qupulse.utils.types import TimeType, MeasurementWindow
-from qupulse.utils.tree import Node, is_tree_circular
-from qupulse.utils.numeric import smallest_factor_ge
+__all__ = qupulse.program.loop.__all__
 
-from qupulse._program.waveforms import SequenceWaveform, RepetitionWaveform
-
-__all__ = ['Loop', 'make_compatible', 'MakeCompatibleWarning']
-
-
-class Loop(Node):
-    MAX_REPR_SIZE = 2000
-    __slots__ = ('_waveform', '_measurements', '_repetition_definition', '_cached_body_duration')
-
-    """This class represents a initialized (sub-)program as a tree. Each Loop of a valid program has a repetition count
-    and either a waveform or a sequence of loops as children.
-    
-    A Loop can have associated measurements which are also repeated.
-    """
-    def __init__(self,
-                 parent: Union['Loop', None] = None,
-                 children: Iterable['Loop'] = (),
-                 waveform: Optional[Waveform] = None,
-                 measurements: Optional[List[MeasurementWindow]] = None,
-                 repetition_count: Union[int, VolatileRepetitionCount] = 1):
-        """Initialize a new loop
-
-        Args:
-            parent: Forwarded to Node.__init__
-            children: Forwarded to Node.__init__
-            waveform: "Payload"
-            measurements: Associated measurements
-            repetition_count: The children / waveform are repeated this often
-        """
-        super().__init__(parent=parent, children=children)
-
-        self._waveform = waveform
-        self._measurements = measurements
-        self._repetition_definition = repetition_count
-        self._cached_body_duration = None
-        assert isinstance(repetition_count, VolatileRepetitionCount) or is_integer(repetition_count)
-        assert isinstance(waveform, (type(None), Waveform))
-
-    def __eq__(self, other: 'Loop') -> bool:
-        if type(self) == type(other):
-            return (self._repetition_definition == other._repetition_definition and
-                    self.waveform == other.waveform and
-                    (self._measurements or None) == (other._measurements or None) and
-                    len(self) == len(other) and
-                    all(self_child == other_child for self_child, other_child in zip(self, other)))
-        else:
-            return NotImplemented
-
-    def append_child(self, loop: Optional['Loop'] = None, **kwargs) -> None:
-        """Append a child to this loop. Either an existing Loop object or a newly created from kwargs
-
-        Args:
-            loop: loop to append
-            **kwargs: Child is constructed with these kwargs
-
-        Raises:
-            ValueError: if called with loop and kwargs
-        """
-        if loop is not None:
-            if kwargs:
-                raise ValueError("Cannot pass a Loop object and Loop constructor arguments at the same time in "
-                                 "append_child")
-            arg = (loop,)
-        else:
-            arg = (kwargs,)
-        super().__setitem__(slice(len(self), len(self)), arg)
-        self._invalidate_duration(body_duration_increment=self[-1].duration)
-
-    def _invalidate_duration(self, body_duration_increment=None):
-        if self._cached_body_duration is not None:
-            if body_duration_increment is not None:
-                self._cached_body_duration += body_duration_increment
-            else:
-                self._cached_body_duration = None
-        if self.parent:
-            if body_duration_increment is not None:
-                self.parent._invalidate_duration(body_duration_increment=body_duration_increment*self.repetition_count)
-            else:
-                self.parent._invalidate_duration()
-
-    def add_measurements(self, measurements: Iterable[MeasurementWindow]):
-        """Add measurements offset by the current body duration i.e. to the END of the current loop
-
-        Args:
-            measurements: Measurements to add
-        """
-        body_duration = float(self.body_duration)
-        if body_duration == 0:
-            measurements = measurements
-        else:
-            measurements = ((mw_name, begin+body_duration, length) for mw_name, begin, length in measurements)
-
-        if self._measurements is None:
-            self._measurements = list(measurements)
-        else:
-            self._measurements.extend(measurements)
-
-    @property
-    def waveform(self) -> Waveform:
-        return self._waveform
-
-    @waveform.setter
-    def waveform(self, val) -> None:
-        self._waveform = val
-        self._invalidate_duration()
-
-    @property
-    def body_duration(self) -> TimeType:
-        if self._cached_body_duration is None:
-            if self.is_leaf():
-                if self.waveform:
-                    self._cached_body_duration = self.waveform.duration
-                else:
-                    self._cached_body_duration = TimeType.from_fraction(0, 1)
-            else:
-                self._cached_body_duration = sum(child.duration for child in self)
-        return self._cached_body_duration
-
-    @property
-    def duration(self) -> TimeType:
-        return self.body_duration * TimeType.from_fraction(self.repetition_count, 1)
-
-    @property
-    def volatile_repetition(self) -> Optional[VolatileProperty]:
-        return getattr(self._repetition_definition, 'volatile_property', None)
-
-    @property
-    def repetition_definition(self) -> Union[int, VolatileRepetitionCount]:
-        return self._repetition_definition
-
-    @repetition_definition.setter
-    def repetition_definition(self, new_definition: Union[int, VolatileRepetitionCount]):
-        self._repetition_definition = new_definition
-
-    @property
-    def repetition_count(self) -> int:
-        return int(self._repetition_definition)
-
-    @repetition_count.setter
-    def repetition_count(self, val: int) -> None:
-        assert isinstance(val, (int, float))
-        new_repetition = int(val)
-        if abs(new_repetition - val) > 1e-10:
-            raise ValueError('Repetition count was not an integer')
-        self._repetition_definition = new_repetition
-
-    def unroll(self) -> None:
-        if self.is_leaf():
-            raise RuntimeError('Leaves cannot be unrolled')
-        if self.volatile_repetition:
-            warnings.warn("Unrolling a Loop with volatile repetition count", VolatileModificationWarning)
-
-        i = self.parent_index
-        self.parent[i:i+1] = (child.copy_tree_structure(new_parent=self.parent)
-                              for _ in range(self.repetition_count)
-                              for child in self)
-        self.parent.assert_tree_integrity()
-
-    def __setitem__(self, idx, value):
-        super().__setitem__(idx, value)
-        self._invalidate_duration()
-
-    def unroll_children(self) -> None:
-        if self.volatile_repetition:
-            warnings.warn("Unrolling a Loop with volatile repetition count", VolatileModificationWarning)
-        old_children = self.children
-        self[:] = (child.copy_tree_structure()
-                   for _ in range(self.repetition_count)
-                   for child in old_children)
-        self.repetition_count = 1
-        self.assert_tree_integrity()
-
-    def encapsulate(self) -> None:
-        """Add a nesting level by moving self to its children."""
-        self[:] = [Loop(children=self,
-                        repetition_count=self._repetition_definition,
-                        waveform=self._waveform,
-                        measurements=self._measurements)]
-        self.repetition_count = 1
-        self._waveform = None
-        self._measurements = None
-        self.assert_tree_integrity()
-
-    def _get_repr(self, first_prefix, other_prefixes) -> Generator[str, None, None]:
-        if self.is_leaf():
-            yield '%sEXEC %r %d times' % (first_prefix, self._waveform, self.repetition_count)
-        else:
-            yield '%sLOOP %d times:' % (first_prefix, self.repetition_count)
-
-            for elem in self:
-                yield from cast(Loop, elem)._get_repr(other_prefixes + '  ->', other_prefixes + '    ')
-
-    def __repr__(self) -> str:
-        is_circular = is_tree_circular(self)
-        if is_circular:
-            return '{}: Circ {}'.format(id(self), is_circular)
-
-        str_len = 0
-        repr_list = []
-        for sub_repr in self._get_repr('', ''):
-            str_len += len(sub_repr)
-
-            if self.MAX_REPR_SIZE and str_len > self.MAX_REPR_SIZE:
-                repr_list.append('...')
-                break
-            else:
-                repr_list.append(sub_repr)
-        return '\n'.join(repr_list)
-
-    def copy_tree_structure(self, new_parent: Union['Loop', bool]=False) -> 'Loop':
-        return type(self)(parent=self.parent if new_parent is False else new_parent,
-                          waveform=self._waveform,
-                          repetition_count=self._repetition_definition,
-                          measurements=None if self._measurements is None else list(self._measurements),
-                          children=(child.copy_tree_structure() for child in self))
-
-    def _get_measurement_windows(self, drop: bool) -> Mapping[str, np.ndarray]:
-        """Private implementation of get_measurement_windows with a slightly different data format for easier tiling.
-
-        Args:
-            drop: Drops the measurements from the Loop i.e. the Loop will no longer have measurements attached after
-            collecting them
-
-        Returns:
-             A dictionary (measurement_name -> array) with begin == array[:, 0] and length == array[:, 1]
-        """
-        temp_meas_windows = defaultdict(list)
-        if self._measurements:
-            for (mw_name, begin, length) in self._measurements:
-                temp_meas_windows[mw_name].append((begin, length))
-
-            for mw_name, begin_length_list in temp_meas_windows.items():
-                temp_meas_windows[mw_name] = [np.asarray(begin_length_list, dtype=float)]
-
-            if drop:
-                self._measurements = None
-
-        # calculate duration together with meas windows in the same iteration
-        if self.is_leaf():
-            body_duration = float(self.body_duration)
-        else:
-            offset = TimeType(0)
-            for child in self:
-                for mw_name, begins_length_array in child._get_measurement_windows(drop).items():
-                    begins_length_array[:, 0] += float(offset)
-                    temp_meas_windows[mw_name].append(begins_length_array)
-                offset += child.duration
-
-            body_duration = float(offset)
-
-        # formatting like this for easier debugging
-        result = {}
-
-        # repeat and add repetition based offset
-        for mw_name, begin_length_list in temp_meas_windows.items():
-            result[mw_name] = _repeat_loop_measurements(begin_length_list, self.repetition_count, body_duration)
-
-        return result
-
-    def get_measurement_windows(self, drop=False) -> Dict[str, Tuple[np.ndarray, np.ndarray]]:
-        """Iterates over all children and collect the begin and length arrays of each measurement window.
-
-        Args:
-            drop: Drops the measurements from the Loop i.e. the Loop will no longer have measurements attached after
-            collecting them.
-
-        Returns:
-            A dictionary (measurement_name -> (begin, length)) with begin and length being :class:`numpy.ndarray`
-        """
-        return {mw_name: (begin_length_list[:, 0], begin_length_list[:, 1])
-                for mw_name, begin_length_list in self._get_measurement_windows(drop=drop).items()}
-
-    def split_one_child(self, child_index=None) -> None:
-        """Take the last child that has a repetition count larger one, decrease it's repetition count and insert a copy
-        with repetition cout one after it"""
-        if child_index is not None:
-            if self[child_index].repetition_count < 2:
-                raise ValueError('Cannot split child {} as the repetition count is not larger 1')
-
-        else:
-            # we cannot reverse enumerate
-            n_child = len(self) - 1
-            for reverse_idx, child in enumerate(reversed(self)):
-                if child.repetition_count > 1:
-                    forward_idx = n_child - reverse_idx
-                    if not child.volatile_repetition:
-                        child_index = forward_idx
-                        break
-                    elif child_index is None:
-                        child_index = forward_idx
-            else:
-                if child_index is None:
-                    raise RuntimeError('There is no child with repetition count > 1')
-
-        if self[child_index].volatile_repetition:
-            warnings.warn("Splitting a child with volatile repetition count", VolatileModificationWarning)
-
-        new_child = self[child_index].copy_tree_structure()
-        new_child.repetition_count = 1
-
-        self[child_index].repetition_count -= 1
-
-        self[child_index+1:child_index+1] = (new_child,)
-        self.assert_tree_integrity()
-
-    def flatten_and_balance(self, depth: int) -> None:
-        """Modifies the program so all tree branches have the same depth.
-
-        Args:
-            depth: Target depth of the program
-        """
-        i = 0
-        while i < len(self):
-            # only used by type checker
-            sub_program = cast(Loop, self[i])
-
-            if sub_program.depth() < depth - 1:
-                # increase nesting because the subprogram is not deep enough
-                sub_program.encapsulate()
-
-            elif not sub_program.is_balanced():
-                # balance the sub program. We revisit it in the next iteration (no change of i )
-                # because it might modify self. While writing this comment I am not sure this is true. 14.01.2020 Simon
-                sub_program.flatten_and_balance(depth - 1)
-
-            elif sub_program.depth() == depth - 1:
-                # subprogram is balanced with the correct depth
-                i += 1
-
-            elif sub_program._has_single_child_that_can_be_merged():
-                # subprogram is balanced but to deep and has no measurements -> we can "lift" the sub-sub-program
-                # TODO: There was a len(sub_sub_program) == 1 check here that I cannot explain
-                sub_program._merge_single_child()
-
-            elif not sub_program.is_leaf():
-                # subprogram is balanced but too deep
-                sub_program.unroll()
-
-            else:
-                # we land in this case if the function gets called with depth == 0 and the current subprogram is a leaf
-                i += 1
-
-    def _has_single_child_that_can_be_merged(self) -> bool:
-        if len(self) == 1:
-            child = cast(Loop, self[0])
-            return not self._measurements or (child.repetition_count == 1 and not child.volatile_repetition)
-        else:
-            return False
-
-    def _merge_single_child(self):
-        """Lift the single child to current level. Requires _has_single_child_that_can_be_merged to be true"""
-        assert len(self) == 1, "bug: _merge_single_child called on loop with len != 1"
-        child = cast(Loop, self[0])
-
-        # if the child has a fixed repetition count of 1 the measurements can be merged
-        mergable_measurements = child.repetition_count == 1 and not child.volatile_repetition
-
-        assert not self._measurements or mergable_measurements, "bug: _merge_single_child called on loop with measurements"
-        assert not self._waveform, "bug: _merge_single_child called on loop with children and waveform"
-
-        measurements = child._measurements
-        if self._measurements:
-            if measurements:
-                measurements.extend(self._measurements)
-            else:
-                measurements = self._measurements
-
-        if not self.volatile_repetition and not child.volatile_repetition:
-            # simple integer multiplication
-            repetition_definition = self.repetition_count * child.repetition_count
-        elif not self.volatile_repetition:
-            repetition_definition = child._repetition_definition * self.repetition_count
-        elif not child.volatile_repetition:
-            repetition_definition = self._repetition_definition * child.repetition_count
-        else:
-            # create a new expression that depends on both
-            expression = 'parent_repetition_count * child_repetition_count'
-            repetition_definition = VolatileRepetitionCount.operation(
-                expression=expression,
-                parent_repetition_count=self._repetition_definition,
-                child_repetition_count=child._repetition_definition)
-
-        self[:] = iter(child)
-        self._waveform = child._waveform
-        self._repetition_definition = repetition_definition
-        self._measurements = measurements
-        self._invalidate_duration()
-        return True
-
-    def cleanup(self, actions=('remove_empty_loops', 'merge_single_child')):
-        """Apply the specified actions to cleanup the Loop.
-
-        remove_empty_loops: Remove loops with no children and no waveform (a DroppedMeasurementWarning is issued)
-        merge_single_child: see `_try_merge_single_child` documentation
-
-        Warnings:
-            DroppedMeasurementWarning: Likely a bug in qupulse. TODO: investigate whether there are usecases
-        """
-        if 'remove_empty_loops' in actions:
-            new_children = []
-            for child in self:
-                child = cast(Loop, child)
-                if child.is_leaf():
-                    if child.waveform is None:
-                        if child._measurements:
-                            warnings.warn("Dropping measurement since there is no waveform attached",
-                                          category=DroppedMeasurementWarning)
-                    else:
-                        new_children.append(child)
-
-                else:
-                    child.cleanup(actions)
-                    if child.waveform or not child.is_leaf():
-                        new_children.append(child)
-
-                    elif child._measurements:
-                        warnings.warn("Dropping measurement since there is no waveform in children",
-                                      category=DroppedMeasurementWarning)
-
-            if len(self) != len(new_children):
-                self[:] = new_children
-
-        else:
-            # only do the recursive call
-            for child in self:
-                child.cleanup(actions)
-
-        if 'merge_single_child' in actions and self._has_single_child_that_can_be_merged():
-            self._merge_single_child()
-    
-    def get_duration_structure(self) -> Tuple[int, Union[TimeType, tuple]]:
-        if self.is_leaf():
-            return self.repetition_count, self.waveform.duration
-        else:
-            return self.repetition_count, tuple(child.get_duration_structure() for child in self)
-
-    def reverse_inplace(self):
-        if self.is_leaf():
-            self._waveform = self._waveform.reversed()
-        else:
-            self._reverse_children()
-            for child in self:
-                child.reverse_inplace()
-        if self._measurements:
-            duration = self.duration
-            self._measurements = [
-                (name, duration - (begin + length), length)
-                for name, begin, length in self._measurements
-            ]
-
-
-class ChannelSplit(Exception):
-    def __init__(self, channel_sets):
-        self.channel_sets = channel_sets
-
-
-def to_waveform(program: Loop) -> Waveform:
-    if program.is_leaf():
-        if program.repetition_count == 1:
-            return program.waveform
-        else:
-            return RepetitionWaveform.from_repetition_count(program.waveform, program.repetition_count)
-    else:
-        if len(program) == 1:
-            sequenced_waveform = to_waveform(cast(Loop, program[0]))
-        else:
-            sequenced_waveform = SequenceWaveform.from_sequence(
-                [to_waveform(cast(Loop, sub_program))
-                 for sub_program in program])
-        if program.repetition_count > 1:
-            return RepetitionWaveform.from_repetition_count(sequenced_waveform, program.repetition_count)
-        else:
-            return sequenced_waveform
-
-
-class _CompatibilityLevel(Enum):
-    compatible = 0
-    action_required = 1
-    incompatible_too_short = 2
-    incompatible_fraction = 3
-    incompatible_quantum = 4
-
-    def is_incompatible(self) -> bool:
-        return self in (self.incompatible_fraction, self.incompatible_quantum, self.incompatible_too_short)
-
-
-def _is_compatible(program: Loop, min_len: int, quantum: int, sample_rate: TimeType) -> _CompatibilityLevel:
-    """ check whether program loop is compatible with awg requirements
-        possible reasons for incompatibility:
-            program shorter than minimum length
-            program duration not an integer
-            program duration not a multiple of quantum """
-    program_duration_in_samples = program.duration * sample_rate
-
-    if program_duration_in_samples.denominator != 1:
-        return _CompatibilityLevel.incompatible_fraction
-
-    if program_duration_in_samples < min_len:
-        return _CompatibilityLevel.incompatible_too_short
-
-    if program_duration_in_samples % quantum > 0:
-        return _CompatibilityLevel.incompatible_quantum
-
-    if program.is_leaf():
-        waveform_duration_in_samples = program.body_duration * sample_rate
-        if waveform_duration_in_samples < min_len or (waveform_duration_in_samples / quantum).denominator != 1:
-            if program.volatile_repetition:
-                warnings.warn("_is_compatible requires an action which drops volatility.",
-                              category=VolatileModificationWarning)
-            return _CompatibilityLevel.action_required
-        else:
-            return _CompatibilityLevel.compatible
-    else:
-        if all(_is_compatible(cast(Loop, sub_program), min_len, quantum, sample_rate) == _CompatibilityLevel.compatible
-               for sub_program in program):
-            return _CompatibilityLevel.compatible
-        else:
-            if program.volatile_repetition:
-                warnings.warn("_is_compatible requires an action which drops volatility.",
-                              category=VolatileModificationWarning)
-            return _CompatibilityLevel.action_required
-
-
-def _make_compatible(program: Loop, min_len: int, quantum: int, sample_rate: TimeType) -> None:
-    if program.is_leaf():
-        program.waveform = to_waveform(program.copy_tree_structure())
-        program.repetition_count = 1
-    else:
-        comp_levels = [_is_compatible(cast(Loop, sub_program), min_len, quantum, sample_rate)
-                       for sub_program in program]
-
-        if any(comp_level.is_incompatible() for comp_level in comp_levels):
-            single_run = program.duration * sample_rate / program.repetition_count
-            if (single_run / quantum).denominator == 1 and single_run >= min_len:
-                # it is enough to concatenate all children
-                new_repetition_definition = program.repetition_definition
-                program.repetition_count = 1
-            else:
-                # we need to concatenate all children and unroll
-                new_repetition_definition = 1
-
-            program.waveform = to_waveform(program.copy_tree_structure())
-            program.repetition_definition = new_repetition_definition
-            program[:] = []
-            return
-        else:
-            for sub_program, comp_level in zip(program, comp_levels):
-                if comp_level == _CompatibilityLevel.action_required:
-                    _make_compatible(sub_program, min_len, quantum, sample_rate)
-
-
-def make_compatible(program: Loop, minimal_waveform_length: int, waveform_quantum: int, sample_rate: TimeType):
-    """ check program for compatibility to AWG requirements, make it compatible if necessary and  possible"""
-    comp_level = _is_compatible(program,
-                                min_len=minimal_waveform_length,
-                                quantum=waveform_quantum,
-                                sample_rate=sample_rate)
-    if comp_level == _CompatibilityLevel.incompatible_fraction:
-        raise ValueError('The program duration in samples {} is not an integer'.format(program.duration * sample_rate))
-    if comp_level == _CompatibilityLevel.incompatible_too_short:
-        raise ValueError('The program is too short to be a valid waveform. \n'
-                         ' program duration in samples: {} \n'
-                         ' minimal length: {}'.format(program.duration * sample_rate, minimal_waveform_length))
-    if comp_level == _CompatibilityLevel.incompatible_quantum:
-        raise ValueError('The program duration in samples {} '
-                         'is not a multiple of quantum {}'.format(program.duration * sample_rate, waveform_quantum))
-
-    elif comp_level == _CompatibilityLevel.action_required:
-        warnings.warn("qupulse will now concatenate waveforms to make the pulse/program compatible with the chosen AWG."
-                      " This might take some time. If you need this pulse more often it makes sense to write it in a "
-                      "way which is more AWG friendly.", MakeCompatibleWarning)
-
-        _make_compatible(program,
-                         min_len=minimal_waveform_length,
-                         quantum=waveform_quantum,
-                         sample_rate=sample_rate)
-
-    else:
-        assert comp_level == _CompatibilityLevel.compatible
-
-
-def roll_constant_waveforms(program: Loop, minimal_waveform_quanta: int, waveform_quantum: int, sample_rate: TimeType):
-    """This function finds waveforms in program that can be replaced with repetitions of shorter waveforms and replaces
-    them. Complexity O(N_waveforms). Drops measurements because they are not correctly handled here for performance
-    reasons.
-
-    This is possible if:
-     - The waveform is constant on all channels
-     - waveform.duration * sample_rate / waveform_quantum has a factor that is bigger than minimal_waveform_quanta
-
-    Args:
-        program:
-        minimal_waveform_quanta:
-        waveform_quantum:
-        sample_rate:
-
-    Warnings:
-        DroppedMeasurementWarning: This warning is raised if a measurement is dropped.
-    """
-    if program._measurements:
-        warnings.warn("Dropping measurements. Remove measurements before calling roll_constant_waveforms by calling"
-                      " get_measurement_windows(drop=True).", category=DroppedMeasurementWarning)
-        program._measurements = None
-
-    waveform = program.waveform
-
-    if waveform is None:
-        for child in program:
-            roll_constant_waveforms(child, minimal_waveform_quanta, waveform_quantum, sample_rate)
-    else:
-        waveform_quanta = (waveform.duration * sample_rate) // waveform_quantum
-
-        # example
-        # waveform_quanta = 15
-        # minimal_waveform_quanta = 2
-        # => repetition_count = 5, new_waveform_quanta = 3
-        if waveform_quanta < minimal_waveform_quanta * 2:
-            # there is no way to roll this waveform because it is too short
-            return
-
-        const_values = waveform.constant_value_dict()
-        if const_values is None:
-            # The waveform is not constant
-            return
-
-        new_waveform_quanta = smallest_factor_ge(waveform_quanta, min_factor=minimal_waveform_quanta)
-        if new_waveform_quanta == waveform_quanta:
-            # the waveform duration in samples has no suitable factor
-            # TODO: Option to insert multiple Loop objects
-            return
-
-        additional_repetition_count = waveform_quanta // new_waveform_quanta
-
-        new_waveform = ConstantWaveform.from_mapping(
-            duration=waveform_quantum * new_waveform_quanta / sample_rate,
-            constant_values=const_values)
-
-        # use the private properties to avoid invalidating the duration cache of the parent loop
-        program._repetition_definition = program.repetition_definition * additional_repetition_count
-        program._waveform = new_waveform
-
-
-def _repeat_loop_measurements(begin_length_list: List[np.ndarray],
-                              repetition_count: int,
-                              body_duration: float
-                              ) -> np.ndarray:
-    temp_begin_length_array = np.concatenate(begin_length_list)
-
-    begin_length_array = np.tile(temp_begin_length_array, (repetition_count, 1))
-
-    shaped_begin_length_array = np.reshape(begin_length_array, (repetition_count, -1, 2))
-
-    shaped_begin_length_array[:, :, 0] += (np.arange(repetition_count) * body_duration)[:, np.newaxis]
-
-    return begin_length_array
-
-
-class MakeCompatibleWarning(ResourceWarning):
-    pass
-
-
-class VolatileModificationWarning(RuntimeWarning):
-    """This warning is emitted if the colatile part of a program gets modified. This might imply that the volatile
-    parameter cannot be change anymore."""
-
-
-class DroppedMeasurementWarning(RuntimeWarning):
-    """This warning is emitted if a measurement was dropped because there was no waveform attached."""
+del qupulse
diff --git a/qupulse/_program/seqc.py b/qupulse/_program/seqc.py
deleted file mode 100644
index 50dd29a7c..000000000
--- a/qupulse/_program/seqc.py
+++ /dev/null
@@ -1,1498 +0,0 @@
-"""This module contains the ZI HDAWG compatible description of programs. There is no code in here that interacts with
-hardware directly.
-
-The public interface to all functionality is given by `HDAWGProgramManager`. This class can create seqc source code
-which contains multiple programs and allows switching between these with the user registers of a device,
-
-Furthermore:
-- `SEQCNode`: AST of a subset of sequencing C
-- `loop_to_seqc`: conversion of `Loop` objects to this subset in a clever way
-- `BinaryWaveform`: Bundles functionality of handling segments in a native way.
-- `WaveformMemory`: Functionality to sync waveforms to the device (via the LabOne user folder)
-- `ProgramWaveformManager` and `HDAWGProgramEntry`: Program wise handling of waveforms and seqc-code
-classes that convert `Loop` objects"""
-import warnings
-from typing import Optional, Union, Sequence, Dict, Iterator, Tuple, Callable, NamedTuple, MutableMapping, Mapping,\
-    Iterable, Any, List, Deque
-from types import MappingProxyType
-import abc
-import itertools
-import inspect
-import logging
-import hashlib
-from weakref import WeakValueDictionary
-from collections import OrderedDict
-import re
-import collections
-import numbers
-import string
-import functools
-
-import numpy as np
-from pathlib import Path
-
-from qupulse.utils.types import ChannelID, TimeType
-from qupulse.utils import replace_multiple, grouper
-from qupulse._program.waveforms import Waveform
-from qupulse._program._loop import Loop
-from qupulse._program.volatile import VolatileRepetitionCount, VolatileProperty
-from qupulse.hardware.awgs.base import ProgramEntry
-from qupulse.hardware.util import zhinst_voltage_to_uint16
-
-try:
-    # zhinst fires a DeprecationWarning from its own code in some versions...
-    with warnings.catch_warnings():
-        warnings.simplefilter('ignore', DeprecationWarning)
-        import zhinst.utils
-except ImportError:
-    zhinst = None
-
-
-__all__ = ["HDAWGProgramManager"]
-
-
-def make_valid_identifier(name: str) -> str:
-    # replace all invalid characters and conactenate with hash of original name
-    name_hash = hashlib.sha256(name.encode('utf-8')).hexdigest()
-    valid_chars = string.ascii_letters + string.digits + '_'
-    namestub = ''.join(c for c in name if c in valid_chars)
-    return f'renamed_{namestub}_{name_hash}'
-
-
-class BinaryWaveform:
-    """This class represents a sampled waveform in the native HDAWG format as returned
-    by zhinst.utils.convert_awg_waveform.
-
-    BinaryWaveform.data can be uploaded directly to {device]/awgs/{awg}/waveform/waves/{wf}
-
-    `to_csv_compatible_table` can be used to create a compatible compact csv file (with marker data included)
-    """
-    __slots__ = ('data',)
-
-    PLAYBACK_QUANTUM = 16
-    PLAYBACK_MIN_QUANTA = 2
-
-    def __init__(self, data: np.ndarray):
-        """ TODO: always use both channels?
-
-        Args:
-            data: data as returned from zhinst.utils.convert_awg_waveform
-        """
-        n_quantum, remainder = divmod(data.size, 3 * self.PLAYBACK_QUANTUM)
-        assert n_quantum > 1, "Waveform too short (min len is 32)"
-        assert remainder == 0, "Waveform has not a valid length"
-        assert data.dtype is np.dtype('uint16')
-        assert np.all(data[2::3] < 16), "invalid marker data"
-        assert data.ndim == 1, "Data not one dimensional"
-
-        self.data = data
-        self.data.flags.writeable = False
-
-    @property
-    def ch1(self):
-        return self.data[::3]
-
-    @property
-    def ch2(self):
-        return self.data[1::3]
-
-    @property
-    def marker_data(self):
-        return self.data[2::3]
-
-    @property
-    def markers_ch1(self):
-        return np.bitwise_and(self.marker_data, 0b0011)
-
-    @property
-    def markers_ch2(self):
-        return np.bitwise_and(self.marker_data, 0b1100)
-
-    @classmethod
-    def from_sampled(cls, ch1: Optional[np.ndarray], ch2: Optional[np.ndarray],
-                     markers: Tuple[Optional[np.ndarray], Optional[np.ndarray],
-                                    Optional[np.ndarray], Optional[np.ndarray]]) -> 'BinaryWaveform':
-        """Combines the sampled and scaled waveform data into a single binary compatible waveform
-
-        Args:
-            ch1: sampled waveform scaled to full range (-1., 1.)
-            ch2: sampled waveform scaled to full range (-1., 1.)
-            markers: (ch1_front_marker, ch1_dio_marker, ch2_front_marker, ch2_dio_marker)
-
-        Returns:
-
-        """
-        return cls(zhinst_voltage_to_uint16(ch1, ch2, markers))
-
-    @classmethod
-    def zeroed(cls, size):
-        return cls(zhinst.utils.convert_awg_waveform(np.zeros(size), np.zeros(size), np.zeros(size, dtype=np.uint16)))
-
-    def __len__(self):
-        return self.data.size // 3
-
-    def __eq__(self, other):
-        return np.array_equal(self.data, other.data)
-
-    def __hash__(self):
-        return hash(bytes(self.data))
-
-    def fingerprint(self) -> str:
-        """This fingerprint is runtime independent"""
-        return hashlib.sha256(self.data).hexdigest()
-
-    def to_csv_compatible_table(self) -> np.ndarray:
-        """The integer values in that file should be 18-bit unsigned integers with the two least significant bits
-        being the markers. The values are mapped to 0 => -FS, 262143 => +FS, with FS equal to the full scale.
-
-        >>> np.savetxt(waveform_dir, binary_waveform.to_csv_compatible_table(), fmt='%u')
-        """
-        assert self.data.size % self.PLAYBACK_QUANTUM == 0, "conversion to csv requires a valid length"
-
-        table = np.zeros((len(self), 2), dtype=np.uint32)
-        table[:, 0] = self.ch1
-        table[:, 1] = self.ch2
-        np.left_shift(table, 2, out=table)
-        table[:, 0] += self.markers_ch1
-        table[:, 1] += self.markers_ch2
-
-        return table
-
-    def playback_possible(self) -> bool:
-        """Returns if the waveform can be played without padding"""
-        return self.data.size % self.PLAYBACK_QUANTUM == 0
-
-    def dynamic_rate(self, max_rate: int = 12) -> int:
-        min_pre_division_quanta = 2 * self.PLAYBACK_QUANTUM
-
-        reduced = self.data.reshape(-1, 3)
-        for n in range(max_rate):
-            n_quantum, remainder = divmod(reduced.shape[0], min_pre_division_quanta)
-            if remainder != 0 or n_quantum < self.PLAYBACK_MIN_QUANTA or np.any(reduced[::2, :] != reduced[1::2, :]):
-                return n
-            reduced = reduced[::2, :]
-        return max_rate
-
-
-class ConcatenatedWaveform:
-    def __init__(self):
-        """Handle the concatenation of multiple binary waveforms to create a big indexable waveform."""
-        self._concatenated: Optional[List[Tuple[BinaryWaveform, ...]]] = []
-        self._as_binary: Optional[Tuple[BinaryWaveform, ...]] = None
-
-    def __bool__(self):
-        return bool(self._concatenated)
-
-    def is_finalized(self):
-        return self._as_binary is not None or self._concatenated is None
-
-    def as_binary(self) -> Optional[Tuple[BinaryWaveform, ...]]:
-        assert self.is_finalized()
-        return self._as_binary
-
-    def append(self, binary_waveform: Tuple[BinaryWaveform, ...]):
-        assert not self.is_finalized()
-        assert not self._concatenated or len(self._concatenated[-1]) == len(binary_waveform)
-        self._concatenated.append(binary_waveform)
-
-    def finalize(self):
-        assert not self.is_finalized()
-        if self._concatenated:
-            n_groups = len(self._concatenated[0])
-            as_binary = [[] for _ in range(n_groups)]
-            for wf_tuple in self._concatenated:
-                for grp, wf in enumerate(wf_tuple):
-                    as_binary[grp].append(wf.data)
-            self._as_binary = tuple(BinaryWaveform(np.concatenate(as_bin)) for as_bin in as_binary)
-        else:
-            self._concatenated = None
-
-    def clear(self):
-        if self._concatenated is None:
-            self._concatenated = []
-        else:
-            self._concatenated.clear()
-        self._as_binary = None
-
-
-class WaveformFileSystem:
-    logger = logging.getLogger('qupulse.hdawg.waveforms')
-    _by_path = WeakValueDictionary()
-
-    def __init__(self, path: Path):
-        """This class coordinates multiple AWGs (channel pairs) using the same file system to store the waveforms.
-
-        Args:
-            path: Waveforms are stored here
-        """
-        self._required = {}
-        self._path = path
-    
-    @classmethod
-    def get_waveform_file_system(cls, path: Path) -> 'WaveformFileSystem':
-        """Get the instance for the given path. Multiple instances that access the same path lead to inconsistencies."""
-        return cls._by_path.setdefault(path, cls(path))
-
-    def sync(self, client: 'WaveformMemory', waveforms: Mapping[str, BinaryWaveform], **kwargs):
-        """Write the required waveforms to the filesystem."""
-        self._required[id(client)] = waveforms
-        self._sync(**kwargs)
-
-    def _sync(self, delete=True, write_all=False):
-        to_save = {self._path.joinpath(file_name): binary
-                   for d in self._required.values()
-                   for file_name, binary in d.items()}
-
-        for existing_file in self._path.iterdir():
-            if not existing_file.is_file():
-                pass
-            elif existing_file in to_save:
-                if not write_all:
-                    self.logger.debug('Skipping %r', existing_file.name)
-                    to_save.pop(existing_file)
-            elif delete:
-                try:
-                    self.logger.debug('Deleting %r', existing_file.name)
-                    existing_file.unlink()
-                except OSError:
-                    self.logger.exception("Error deleting: %r", existing_file.name)
-
-        for file_name, binary_waveform in to_save.items():
-            table = binary_waveform.to_csv_compatible_table()
-            np.savetxt(file_name, table, '%u')
-            self.logger.debug('Wrote %r', file_name)
-
-
-class WaveformMemory:
-    """Global waveform "memory" representation (currently the file system)"""
-    CONCATENATED_WAVEFORM_TEMPLATE = '{program_name}_concatenated_waveform_{group_index}'
-    SHARED_WAVEFORM_TEMPLATE = '{program_name}_shared_waveform_{hash}'
-    WF_PLACEHOLDER_TEMPLATE = '*{id}*'
-    FILE_NAME_TEMPLATE = '{hash}.csv'
-
-    _WaveInfo = NamedTuple('_WaveInfo', [('wave_name', str),
-                                         ('file_name', str),
-                                         ('binary_waveform', BinaryWaveform)])
-
-    def __init__(self):
-        self.shared_waveforms = OrderedDict()  # type: MutableMapping[BinaryWaveform, set]
-        self.concatenated_waveforms = OrderedDict()  # type: MutableMapping[str, ConcatenatedWaveform]
-
-    def clear(self):
-        self.shared_waveforms.clear()
-        self.concatenated_waveforms.clear()
-
-    def _shared_waveforms_iter(self) -> Iterator[Tuple[str, _WaveInfo]]:
-        for wf, program_set in self.shared_waveforms.items():
-            if program_set:
-                wave_hash = wf.fingerprint()
-                wave_name = self.SHARED_WAVEFORM_TEMPLATE.format(program_name='_'.join(program_set),
-                                                                 hash=wave_hash)
-                wave_placeholder = self.WF_PLACEHOLDER_TEMPLATE.format(id=id(program_set))
-                file_name = self.FILE_NAME_TEMPLATE.format(hash=wave_hash)
-                yield wave_placeholder, self._WaveInfo(wave_name, file_name, wf)
-
-    def _concatenated_waveforms_iter(self) -> Iterator[Tuple[str, Tuple[_WaveInfo, ...]]]:
-        for program_name, concatenated_waveform in self.concatenated_waveforms.items():
-            # we assume that if the first entry is not empty the rest also isn't
-            if concatenated_waveform:
-                infos = []
-                for group_index, binary in enumerate(concatenated_waveform.as_binary()):
-                    wave_hash = binary.fingerprint()
-                    wave_name = self.CONCATENATED_WAVEFORM_TEMPLATE.format(program_name=program_name,
-                                                                           group_index=group_index)
-                    file_name = self.FILE_NAME_TEMPLATE.format(hash=wave_hash)
-                    infos.append(self._WaveInfo(wave_name, file_name, binary))
-
-                wave_placeholder = self.WF_PLACEHOLDER_TEMPLATE.format(id=id(concatenated_waveform))
-                yield wave_placeholder, tuple(infos)
-
-    def _all_info_iter(self) -> Iterator[_WaveInfo]:
-        for _, infos in self._concatenated_waveforms_iter():
-            yield from infos
-        for _, info in self._shared_waveforms_iter():
-            yield info
-
-    def waveform_name_replacements(self) -> Dict[str, str]:
-        """replace place holders of complete seqc program with
-
-        >>> waveform_name_translation = waveform_memory.waveform_name_replacements()
-        >>> seqc_program = qupulse.utils.replace_multiple(seqc_program, waveform_name_translation)
-        """
-        translation = {}
-        for wave_placeholder, wave_info in self._shared_waveforms_iter():
-            translation[wave_placeholder] = wave_info.wave_name
-
-        for wave_placeholder, wave_infos in self._concatenated_waveforms_iter():
-            translation[wave_placeholder] = ','.join(info.wave_name for info in wave_infos)
-        return translation
-
-    def waveform_declaration(self) -> str:
-        """Produces a string that declares all needed waveforms.
-        It is needed to know the waveform index in case we want to update a waveform during playback."""
-        declarations = []
-        for wave_info in self._all_info_iter():
-            declarations.append(
-                'wave {wave_name} = "{file_name}";'.format(wave_name=wave_info.wave_name,
-                                                           file_name=wave_info.file_name.replace('.csv', ''))
-            )
-        return '\n'.join(declarations)
-
-    def sync_to_file_system(self, file_system: WaveformFileSystem):
-        to_save = {wave_info.file_name: wave_info.binary_waveform
-                   for wave_info in self._all_info_iter()}
-        file_system.sync(self, to_save)
-
-
-class ProgramWaveformManager:
-    """Manages waveforms of a program"""
-    def __init__(self, name: str, memory: WaveformMemory):
-        if not name.isidentifier():
-            waveform_name = make_valid_identifier(name)
-        else:
-            waveform_name = name
-        
-        self._waveform_name = waveform_name
-        self._program_name = name
-        self._memory = memory
-
-        assert self._program_name not in self._memory.concatenated_waveforms
-        assert all(self._program_name not in programs for programs in self._memory.shared_waveforms.values())
-        self._memory.concatenated_waveforms[waveform_name] = ConcatenatedWaveform()
-
-    @property
-    def program_name(self) -> str:
-        return self._program_name
-    
-    @property
-    def main_waveform_name(self) -> str:
-        self._waveform_name
-
-    def clear_requested(self):
-        for programs in self._memory.shared_waveforms.values():
-            programs.discard(self._program_name)
-        self._memory.concatenated_waveforms[self._waveform_name].clear()
-
-    def request_shared(self, binary_waveform: Tuple[BinaryWaveform, ...]) -> str:
-        """Register waveform if not already registered and return a unique identifier placeholder.
-
-        The unique identifier currently is computed from the id of the set which stores all programs using this
-        waveform.
-        """
-        placeholders = []
-        for wf in binary_waveform:
-            program_set = self._memory.shared_waveforms.setdefault(wf, set())
-            program_set.add(self._program_name)
-            placeholders.append(self._memory.WF_PLACEHOLDER_TEMPLATE.format(id=id(program_set)))
-        return ",".join(placeholders)
-
-    def request_concatenated(self, binary_waveform: Tuple[BinaryWaveform, ...]) -> str:
-        """Append the waveform to the concatenated waveform"""
-        bin_wf_list = self._memory.concatenated_waveforms[self._waveform_name]
-        bin_wf_list.append(binary_waveform)
-        return self._memory.WF_PLACEHOLDER_TEMPLATE.format(id=id(bin_wf_list))
-
-    def finalize(self):
-        self._memory.concatenated_waveforms[self._waveform_name].finalize()
-
-    def prepare_delete(self):
-        """Delete all references in waveform memory to this program. Cannot be used afterwards."""
-        self.clear_requested()
-        del self._memory.concatenated_waveforms[self._waveform_name]
-
-
-class UserRegister:
-    """This class is a helper class to avoid errors due to 0 and 1 based register indexing"""
-    __slots__ = ('_zero_based_value',)
-
-    def __init__(self, *, zero_based_value: int = None, one_based_value: int = None):
-        assert None in (zero_based_value, one_based_value)
-        assert isinstance(zero_based_value, int) or isinstance(one_based_value, int)
-
-        if one_based_value is not None:
-            assert one_based_value > 0, "A one based value needs to be larger zero"
-            self._zero_based_value = one_based_value - 1
-        else:
-            self._zero_based_value = zero_based_value
-
-    @classmethod
-    def from_seqc(cls, value: int) -> 'UserRegister':
-        return cls(zero_based_value=value)
-
-    def to_seqc(self) -> int:
-        return self._zero_based_value
-
-    @classmethod
-    def from_labone(cls, value: int) -> 'UserRegister':
-        return cls(zero_based_value=value)
-
-    def to_labone(self) -> int:
-        return self._zero_based_value
-
-    @classmethod
-    def from_web_interface(cls, value: int) -> 'UserRegister':
-        return cls(one_based_value=value)
-
-    def to_web_interface(self) -> int:
-        return self._zero_based_value + 1
-
-    def __hash__(self):
-        return hash(self._zero_based_value)
-
-    def __eq__(self, other):
-        return self._zero_based_value == getattr(other, '_zero_based_value', None)
-
-    def __repr__(self):
-        return 'UserRegister(zero_based_value={zero_based_value})'.format(zero_based_value=self._zero_based_value)
-
-    def __format__(self, format_spec: str) -> str:
-        if format_spec in ('zero_based', 'seqc', 'labone', 'lab_one'):
-            return str(self.to_seqc())
-        elif format_spec in ('one_based', 'web', 'web_interface'):
-            return str(self.to_web_interface())
-        elif format_spec in ('repr', 'r'):
-            return repr(self)
-        else:
-            raise ValueError('Invalid format spec for UserRegister: ', format_spec)
-
-
-class UserRegisterManager:
-    """This class keeps track of the user registered that are used in a certain context"""
-    def __init__(self, available: Iterable[UserRegister], name_template: str):
-        assert 'register' in (x[1] for x in string.Formatter().parse(name_template))
-
-        self._available = set(available)
-        self._name_template = name_template
-        self._used = {}
-
-    def request(self, obj) -> str:
-        """Request a user register name to store object. If an object that evaluates equal to obj was requested before
-        the name name is returned.
-
-        Args:
-            obj: Object to store
-
-        Returns:
-            Name of the variable with the user register
-
-        Raises:
-            Value error if no register is available
-        """
-        for register, registered_obj in self._used.items():
-            if obj == registered_obj:
-                return self._name_template.format(register=register)
-        if self._available:
-            register = self._available.pop()
-            self._used[register] = obj
-            return self._name_template.format(register=register)
-        else:
-            raise ValueError("No register available for %r" % obj)
-
-    def iter_used_register_names(self) -> Iterator[Tuple[UserRegister, str]]:
-        """
-
-        Returns:
-            An iterator over (register index, register name) pairs
-        """
-        return ((register, self._name_template.format(register=register)) for register in self._used.keys())
-
-    def iter_used_register_values(self) -> Iterable[Tuple[UserRegister, Any]]:
-        return self._used.items()
-
-
-class HDAWGProgramEntry(ProgramEntry):
-    USER_REG_NAME_TEMPLATE = 'user_reg_{register:seqc}'
-
-    def __init__(self, loop: Loop, selection_index: int, waveform_memory: WaveformMemory, program_name: str,
-                 channels: Tuple[Optional[ChannelID], ...],
-                 markers: Tuple[Optional[ChannelID], ...],
-                 amplitudes: Tuple[float, ...],
-                 offsets: Tuple[float, ...],
-                 voltage_transformations: Tuple[Optional[Callable], ...],
-                 sample_rate: TimeType):
-        super().__init__(loop, channels=channels, markers=markers,
-                         amplitudes=amplitudes,
-                         offsets=offsets,
-                         voltage_transformations=voltage_transformations,
-                         sample_rate=sample_rate)
-        for waveform, (all_sampled_channels, all_sampled_markers) in self._waveforms.items():
-            size = int(waveform.duration * sample_rate)
-
-            # group in channel pairs for binary waveform
-            binary_waveforms = []
-            for (sampled_channels, sampled_markers) in zip(grouper(all_sampled_channels, 2),
-                                                           grouper(all_sampled_markers, 4)):
-                if all(x is None for x in (*sampled_channels, *sampled_markers)):
-                    # empty channel pairs
-                    binary_waveforms.append(BinaryWaveform.zeroed(size))
-                else:
-                    binary_waveforms.append(BinaryWaveform.from_sampled(*sampled_channels, sampled_markers))
-            self._waveforms[waveform] = tuple(binary_waveforms)
-
-        self._waveform_manager = ProgramWaveformManager(program_name, waveform_memory)
-        self.selection_index = selection_index
-        self._trigger_wait_code = None
-        self._seqc_node = None
-        self._seqc_source = None
-        self._var_declarations = None
-        self._user_registers = None
-        self._user_register_source = None
-
-    def compile(self,
-                min_repetitions_for_for_loop: int,
-                min_repetitions_for_shared_wf: int,
-                indentation: str,
-                trigger_wait_code: str,
-                available_registers: Iterable[UserRegister]):
-        """Compile the loop representation to an internal sequencing c one using `loop_to_seqc`
-
-        Args:
-            min_repetitions_for_for_loop: See `loop_to_seqc`
-            min_repetitions_for_shared_wf: See `loop_to_seqc`
-            indentation: Each line is prefixed with this
-            trigger_wait_code: The code is put before the playback start
-            available_registers
-        Returns:
-
-        """
-        pos_var_name = 'pos'
-
-        if self._seqc_node:
-            self._waveform_manager.clear_requested()
-
-        user_registers = UserRegisterManager(available_registers, self.USER_REG_NAME_TEMPLATE)
-
-        self._seqc_node = loop_to_seqc(self._loop,
-                                       min_repetitions_for_for_loop=min_repetitions_for_for_loop,
-                                       min_repetitions_for_shared_wf=min_repetitions_for_shared_wf,
-                                       waveform_to_bin=self.get_binary_waveform,
-                                       user_registers=user_registers)
-
-        self._user_register_source = '\n'.join(
-            '{indentation}var {user_reg_name} = getUserReg({register});'.format(indentation=indentation,
-                                                                                user_reg_name=user_reg_name,
-                                                                                register=register.to_seqc())
-            for register, user_reg_name in user_registers.iter_used_register_names()
-        )
-        self._user_registers = user_registers
-
-        self._var_declarations = '{indentation}var {pos_var_name} = 0;'.format(pos_var_name=pos_var_name,
-                                                                               indentation=indentation)
-        self._trigger_wait_code = indentation + trigger_wait_code
-        self._seqc_source = '\n'.join(self._seqc_node.to_source_code(self._waveform_manager,
-                                                                     map(str, itertools.count(1)),
-                                                                     line_prefix=indentation,
-                                                                     pos_var_name=pos_var_name))
-        self._waveform_manager.finalize()
-
-    @property
-    def seqc_node(self) -> 'SEQCNode':
-        assert self._seqc_node is not None, "compile not called"
-        return self._seqc_node
-
-    @property
-    def seqc_source(self) -> str:
-        assert self._seqc_source is not None, "compile not called"
-        return '\n'.join([self._var_declarations,
-                          self._user_register_source,
-                          self._trigger_wait_code,
-                          self._seqc_source])
-
-    def volatile_repetition_counts(self) -> Iterable[Tuple[UserRegister, VolatileRepetitionCount]]:
-        """
-        Returns:
-            An iterator over the register and parameter
-        """
-        assert self._user_registers is not None, "compile not called"
-        return self._user_registers.iter_used_register_values()
-
-    @property
-    def name(self) -> str:
-        return self._waveform_manager.program_name
-
-    def parse_to_seqc(self, waveform_memory):
-        raise NotImplementedError()
-
-    def get_binary_waveform(self, waveform: Waveform) -> Tuple[BinaryWaveform, ...]:
-        return self._waveforms[waveform]
-
-    def prepare_delete(self):
-        """Delete all references to this program. Cannot be used afterwards"""
-        self._waveform_manager.prepare_delete()
-        self._seqc_node = None
-        self._seqc_source = None
-
-
-class HDAWGProgramManager:
-    """This class contains everything that is needed to create the final seqc program and provides an interface to write
-    the required waveforms to the file system. It does not talk to the device."""
-
-    class Constants:
-        PROG_SEL_REGISTER = UserRegister(zero_based_value=0)
-        TRIGGER_REGISTER = UserRegister(zero_based_value=1)
-        TRIGGER_RESET_MASK = bin(1 << 31)
-        PROG_SEL_NONE = 0
-        # if not set the register is set to PROG_SEL_NONE
-        NO_RESET_MASK = bin(1 << 31)
-        # set to one if playback finished
-        PLAYBACK_FINISHED_MASK = bin(1 << 30)
-        PROG_SEL_MASK = bin((1 << 30) - 1)
-        INVERTED_PROG_SEL_MASK = bin(((1 << 32) - 1) ^ int(PROG_SEL_MASK, 2))
-        IDLE_WAIT_CYCLES = 300
-
-        @classmethod
-        def as_dict(cls) -> Dict[str, Any]:
-            return {name: value
-                    for name, value in vars(cls).items()
-                    if name[0] in string.ascii_uppercase}
-
-    class GlobalVariables:
-        """Global variables of the program together with their (multiline) doc string.
-        The python names are uppercase."""
-
-        PROG_SEL = (['Selected program index (0 -> None)'], 0)
-        NEW_PROG_SEL = (('Value that gets written back to program selection register.',
-                         'Used to signal that at least one program was played completely.'), 0)
-        PLAYBACK_FINISHED = (('Is OR\'ed to new_prog_sel.',
-                              'Set to PLAYBACK_FINISHED_MASK if a program was played completely.',), 0)
-
-        @classmethod
-        def as_dict(cls) -> Dict[str, Tuple[Sequence[str], int]]:
-            return {name: value
-                    for name, value in vars(cls).items()
-                    if name[0] in string.ascii_uppercase}
-
-        @classmethod
-        def get_init_block(cls) -> str:
-            lines = ['// Declare and initialize global variables']
-            for var_name, (comment, initial_value) in cls.as_dict().items():
-                lines.extend(f'// {comment_line}' for comment_line in comment)
-                lines.append(f'var {var_name.lower()} = {initial_value};')
-                lines.append('')
-            return '\n'.join(lines)
-
-    _PROGRAM_FUNCTION_NAME_TEMPLATE = '{program_name}_function'
-    WAIT_FOR_SOFTWARE_TRIGGER = "waitForSoftwareTrigger();"
-    SOFTWARE_WAIT_FOR_TRIGGER_FUNCTION_DEFINITION = (
-        'void waitForSoftwareTrigger() {\n'
-        '  while (true) {\n'
-        '    var trigger_register = getUserReg(TRIGGER_REGISTER);\n'
-        '    if (trigger_register & TRIGGER_RESET_MASK) setUserReg(TRIGGER_REGISTER, 0);\n'
-        '    if (trigger_register) return;\n'
-        '  }\n'
-        '}\n'
-    )
-    DEFAULT_COMPILER_SETTINGS = {
-        'trigger_wait_code': WAIT_FOR_SOFTWARE_TRIGGER,
-        'min_repetitions_for_for_loop': 20,
-        'min_repetitions_for_shared_wf': 1000,
-        'indentation': '  '
-    }
-    
-    @classmethod
-    def get_program_function_name(cls, program_name: str):
-        if not program_name.isidentifier():
-            program_name = make_valid_identifier(program_name)
-        return cls._PROGRAM_FUNCTION_NAME_TEMPLATE.format(program_name=program_name)
-
-    def __init__(self):
-        self._waveform_memory = WaveformMemory()
-        self._programs = OrderedDict()  # type: MutableMapping[str, HDAWGProgramEntry]
-        self._compiler_settings = [
-            # default settings: None -> take cls value
-            (re.compile('.*'), {'trigger_wait_code': None,
-                                'min_repetitions_for_for_loop': None,
-                                'min_repetitions_for_shared_wf': None,
-                                'indentation': None})]
-
-    def _get_compiler_settings(self, program_name: str) -> dict:
-        arg_spec = inspect.getfullargspec(HDAWGProgramEntry.compile)
-        required_compiler_args = (set(arg_spec.args) | set(arg_spec.kwonlyargs)) - {'self', 'available_registers'}
-
-        settings = {}
-        for regex, settings_dict in self._compiler_settings:
-            if regex.match(program_name):
-                settings.update(settings_dict)
-        if required_compiler_args - set(settings):
-            raise ValueError('Not all compiler arguments for program have been defined.'
-                             ' (the default catch all has been removed)'
-                             f'Missing: {required_compiler_args - set(settings)}')
-        for k, v in settings.items():
-            if v is None:
-                settings[k] = self.DEFAULT_COMPILER_SETTINGS[k]
-        return settings
-
-    @property
-    def waveform_memory(self):
-        return self._waveform_memory
-
-    def _get_low_unused_index(self):
-        existing = {entry.selection_index for entry in self._programs.values()}
-        for idx in itertools.count():
-            if idx not in existing and idx != self.Constants.PROG_SEL_NONE:
-                return idx
-
-    def add_program(self, name: str, loop: Loop,
-                    channels: Tuple[Optional[ChannelID], ...],
-                    markers: Tuple[Optional[ChannelID], ...],
-                    amplitudes: Tuple[float, ...],
-                    offsets: Tuple[float, ...],
-                    voltage_transformations: Tuple[Optional[Callable], ...],
-                    sample_rate: TimeType):
-        """Register the given program and translate it to seqc.
-
-        TODO: Add an interface to change the trigger mode
-
-        Args:
-            name: Human readable name of the program (used f.i. for the function name)
-            loop: The program to upload
-            channels: see AWG.upload
-            markers: see AWG.upload
-            amplitudes: Used to sample the waveforms
-            offsets: Used to sample the waveforms
-            voltage_transformations: see AWG.upload
-            sample_rate: Used to sample the waveforms
-        """
-        assert name not in self._programs
-
-        selection_index = self._get_low_unused_index()
-
-        # TODO: verify total number of registers
-        available_registers = [UserRegister.from_seqc(idx) for idx in range(2, 16)]
-
-        program_entry = HDAWGProgramEntry(loop, selection_index, self._waveform_memory, name,
-                                          channels, markers, amplitudes, offsets, voltage_transformations, sample_rate)
-
-        compiler_settings = self._get_compiler_settings(program_name=name)
-
-        # TODO: put compilation in seperate function
-        program_entry.compile(**compiler_settings,
-                              available_registers=available_registers)
-
-        self._programs[name] = program_entry
-
-    def get_register_values(self, name: str) -> Mapping[UserRegister, int]:
-        return {register: int(parameter)
-                for register, parameter in self._programs[name].volatile_repetition_counts()}
-
-    def get_register_values_to_update_volatile_parameters(self, name: str,
-                                                          parameters: Mapping[str,
-                                                                              numbers.Number]) -> Mapping[UserRegister,
-                                                                                                          int]:
-        """
-
-        Args:
-            name: Program name
-            parameters: new values for volatile parameters
-
-        Returns:
-            A dict user_register->value that reflects the new parameter values
-        """
-        program_entry = self._programs[name]
-        result = {}
-        for register, volatile_repetition in program_entry.volatile_repetition_counts():
-            new_value = volatile_repetition.update_volatile_dependencies(parameters)
-            result[register] = new_value
-        return result
-
-    @property
-    def programs(self) -> Mapping[str, HDAWGProgramEntry]:
-        return MappingProxyType(self._programs)
-
-    def remove(self, name: str) -> None:
-        self._programs.pop(name).prepare_delete()
-
-    def clear(self) -> None:
-        self._waveform_memory.clear()
-        self._programs.clear()
-
-    def name_to_index(self, name: str) -> int:
-        assert self._programs[name].name == name
-        return self._programs[name].selection_index
-
-    def _get_sub_program_source_code(self, program_name: str) -> str:
-        program = self.programs[program_name]
-        program_function_name = self.get_program_function_name(program_name)
-        return "\n".join(
-            [
-                f"void {program_function_name}() {{",
-                program.seqc_source,
-                "}\n"
-            ]
-        )
-
-    def _get_program_selection_code(self) -> str:
-        return _make_program_selection_block((program.selection_index, self.get_program_function_name(program_name))
-                                             for program_name, program in self.programs.items())
-
-    def to_seqc_program(self, single_program: Optional[str] = None) -> str:
-        """Generate sequencing c source code that is either capable of playing pack all uploaded programs where the
-        program is selected at runtime without re-compile or always will play the same program if `single_program`
-        is specified.
-
-         The program selection is based on a user register in the first case.
-
-        Args:
-            single_program: The seqc source only contains this program if not None
-
-        Returns:
-            SEQC source code.
-        """
-        lines = []
-        for const_name, const_val in self.Constants.as_dict().items():
-            if isinstance(const_val, (int, str)):
-                const_repr = str(const_val)
-            else:
-                const_repr = const_val.to_seqc()
-            lines.append('const {const_name} = {const_repr};'.format(const_name=const_name, const_repr=const_repr))
-
-        lines.append(self._waveform_memory.waveform_declaration())
-
-        lines.append('\n// function used by manually triggered programs')
-        lines.append(self.SOFTWARE_WAIT_FOR_TRIGGER_FUNCTION_DEFINITION)
-
-        replacements = self._waveform_memory.waveform_name_replacements()
-
-        lines.append('\n// program definitions')
-        if single_program:
-            lines.append(
-                replace_multiple(self._get_sub_program_source_code(single_program), replacements)
-            )
-
-        else:
-            for program_name, program in self.programs.items():
-                lines.append(replace_multiple(self._get_sub_program_source_code(program_name), replacements))
-
-        lines.append(self.GlobalVariables.get_init_block())
-
-        lines.append('\n// runtime block')
-        if single_program:
-            lines.append(f"{self.get_program_function_name(single_program)}();")
-        else:
-            lines.append(self._get_program_selection_code())
-        
-        return '\n'.join(lines)
-
-
-def find_sharable_waveforms(node_cluster: Sequence['SEQCNode']) -> Optional[Sequence[bool]]:
-    """Expects nodes to have a compatible stepping
-
-    TODO: encode in type system?
-    """
-    waveform_playbacks = list(node_cluster[0].iter_waveform_playbacks())
-
-    candidates = [True] * len(waveform_playbacks)
-
-    for node in itertools.islice(node_cluster, 1, None):
-        candidates_left = False
-        for idx, (wf, node_wf) in enumerate(zip(waveform_playbacks, node.iter_waveform_playbacks())):
-            if candidates[idx]:
-                candidates[idx] = wf == node_wf
-            candidates_left = candidates_left or candidates[idx]
-
-        if not candidates_left:
-            return None
-
-    return candidates
-
-
-def mark_sharable_waveforms(node_cluster: Sequence['SEQCNode'], sharable_waveforms: Sequence[bool]):
-    for node in node_cluster:
-        for sharable, wf_playback in zip(sharable_waveforms, node.iter_waveform_playbacks()):
-            if sharable:
-                wf_playback.shared = True
-
-
-def _find_repetition(nodes: Deque['SEQCNode'],
-                     hashes: Deque[int],
-                     cluster_dump: List[List['SEQCNode']]) -> Tuple[
-    Tuple['SEQCNode', ...],
-    Tuple[int, ...],
-    List['SEQCNode']
-]:
-    """Finds repetitions of stepping patterns in nodes. Assumes hashes contains the stepping_hash of each node. If a
-    pattern is """
-    assert len(nodes) == len(hashes)
-
-    max_cluster_size = len(nodes) // 2
-    for cluster_size in range(max_cluster_size, 0, -1):
-        n_repetitions = len(nodes) // cluster_size
-        for c_idx in range(cluster_size):
-            idx_a = -1 - c_idx
-
-            for n in range(1, n_repetitions):
-                idx_b = idx_a - n * cluster_size
-                if hashes[idx_a] != hashes[idx_b] or not nodes[idx_a].same_stepping(nodes[idx_b]):
-                    n_repetitions = n
-                    break
-
-            if n_repetitions < 2:
-                break
-
-        else:
-            assert n_repetitions > 1
-            # found a stepping pattern repetition of length cluster_size!
-            to_dump = len(nodes) - (n_repetitions * cluster_size)
-            for _ in range(to_dump):
-                cluster_dump.append([nodes.popleft()])
-                hashes.popleft()
-
-            assert len(nodes) == n_repetitions * cluster_size
-
-            if cluster_size == 1:
-                current_cluster = list(nodes)
-
-                cluster_template_hashes = (hashes.popleft(),)
-                cluster_template: Tuple[SEQCNode] = (nodes.popleft(),)
-
-                nodes.clear()
-                hashes.clear()
-
-            else:
-                cluster_template_hashes = tuple(hashes.popleft() for _ in range(cluster_size))
-                cluster_template = tuple(
-                    nodes.popleft() for _ in range(cluster_size)
-                )
-
-                current_cluster: List[SEQCNode] = [Scope(list(cluster_template))]
-
-                for n in range(1, n_repetitions):
-                    current_cluster.append(Scope([
-                        nodes.popleft() for _ in range(cluster_size)
-                    ]))
-                assert not nodes
-                hashes.clear()
-
-            return cluster_template, cluster_template_hashes, current_cluster
-    return (), (), []
-
-
-def to_node_clusters(loop: Union[Sequence[Loop], Loop], loop_to_seqc_kwargs: dict) -> Sequence[Sequence['SEQCNode']]:
-    """transform to seqc recursively noes and cluster them if they have compatible stepping"""
-    assert len(loop) > 1
-
-    # complexity: O( len(loop) * MAX_SUB_CLUSTER * loop.depth() )
-    # I hope...
-    MAX_SUB_CLUSTER = 4
-
-    node_clusters: List[List[SEQCNode]] = []
-
-    # this is the period that we currently are collecting
-    current_period: List[SEQCNode] = []
-
-    # list of already collected periods. Each period is transformed into a SEQCNode
-    current_cluster: List[SEQCNode] = []
-
-    # this is a template for what we are currently collecting
-    current_template: Tuple[SEQCNode, ...] = ()
-    current_template_hashes: Tuple[int, ...] = ()
-
-    # only populated if we are looking for a node template
-    last_node = loop_to_seqc(loop[0], **loop_to_seqc_kwargs)
-    last_hashes = collections.deque([last_node.stepping_hash()], maxlen=MAX_SUB_CLUSTER*2)
-    last_nodes = collections.deque([last_node], maxlen=MAX_SUB_CLUSTER*2)
-
-    # compress all nodes in clusters of the same stepping
-    for child in itertools.islice(loop, 1, None):
-        current_node = loop_to_seqc(child, **loop_to_seqc_kwargs)
-        current_hash = current_node.stepping_hash()
-
-        if current_template:
-            # we are currently collecting something
-            idx = len(current_period)
-            if current_template_hashes[idx] == current_hash and current_node.same_stepping(current_template[idx]):
-                current_period.append(current_node)
-
-                if len(current_period) == len(current_template):
-                    if idx == 0:
-                        node = current_period.pop()
-                    else:
-                        node = Scope(current_period)
-                        current_period = []
-                    current_cluster.append(node)
-
-            else:
-                # current template became invalid
-                assert len(current_cluster) > 1
-                node_clusters.append(current_cluster)
-
-                assert not last_nodes
-                assert not last_hashes
-                last_nodes.extend(current_period)
-                last_hashes.extend(current_template_hashes[:len(current_period)])
-
-                current_period.clear()
-
-                last_nodes.append(current_node)
-                last_hashes.append(current_hash)
-
-                (current_template,
-                 current_template_hashes,
-                 current_cluster) = _find_repetition(last_nodes, last_hashes,
-                                                     node_clusters)
-        else:
-            assert not current_period
-            if len(last_nodes) == last_nodes.maxlen:
-                # lookup deque is full
-                node_clusters.append([last_nodes.popleft()])
-                last_hashes.popleft()
-
-            last_nodes.append(current_node)
-            last_hashes.append(current_hash)
-
-            (current_template,
-             current_template_hashes,
-             current_cluster) = _find_repetition(last_nodes, last_hashes,
-                                                 node_clusters)
-
-    assert not (current_cluster and last_nodes)
-    if current_cluster:
-        node_clusters.append(current_cluster)
-    node_clusters.extend([node] for node in current_period)
-    node_clusters.extend([node] for node in last_nodes)
-
-    return node_clusters
-
-
-def loop_to_seqc(loop: Loop,
-                 min_repetitions_for_for_loop: int,
-                 min_repetitions_for_shared_wf: int,
-                 waveform_to_bin: Callable[[Waveform], Tuple[BinaryWaveform, ...]],
-                 user_registers: UserRegisterManager) -> 'SEQCNode':
-    assert min_repetitions_for_for_loop <= min_repetitions_for_shared_wf
-    # At which point do we switch from indexed to shared
-
-    if loop.is_leaf():
-        node = WaveformPlayback(waveform_to_bin(loop.waveform))
-
-    elif len(loop) == 1:
-        node = loop_to_seqc(loop[0],
-                            min_repetitions_for_for_loop=min_repetitions_for_for_loop,
-                            min_repetitions_for_shared_wf=min_repetitions_for_shared_wf,
-                            waveform_to_bin=waveform_to_bin, user_registers=user_registers)
-
-    else:
-        node_clusters = to_node_clusters(loop, dict(min_repetitions_for_for_loop=min_repetitions_for_for_loop,
-                                                    min_repetitions_for_shared_wf=min_repetitions_for_shared_wf,
-                                                    waveform_to_bin=waveform_to_bin,
-                                                    user_registers=user_registers))
-
-        seqc_nodes = []
-
-        # identify shared waveforms in node clusters
-        for node_cluster in node_clusters:
-            if len(node_cluster) < min_repetitions_for_for_loop:
-                seqc_nodes.extend(node_cluster)
-
-            else:
-                if len(node_cluster) >= min_repetitions_for_shared_wf:
-                    sharable_waveforms = find_sharable_waveforms(node_cluster)
-                    if sharable_waveforms:
-                        mark_sharable_waveforms(node_cluster, sharable_waveforms)
-
-                seqc_nodes.append(SteppingRepeat(node_cluster))
-
-        node = Scope(seqc_nodes)
-
-    if loop.volatile_repetition:
-        register_var = user_registers.request(loop.repetition_definition)
-        return Repeat(scope=node, repetition_count=register_var)
-
-    elif loop.repetition_count != 1:
-        return Repeat(scope=node, repetition_count=loop.repetition_count)
-    else:
-        return node
-
-
-class SEQCNode(metaclass=abc.ABCMeta):
-    __slots__ = ()
-
-    INDENTATION = '  '
-
-    @abc.abstractmethod
-    def samples(self) -> int:
-        pass
-
-    @abc.abstractmethod
-    def stepping_hash(self) -> int:
-        """hash of the stepping properties of this node"""
-
-    @abc.abstractmethod
-    def same_stepping(self, other: 'SEQCNode'):
-        pass
-
-    @abc.abstractmethod
-    def iter_waveform_playbacks(self) -> Iterator['WaveformPlayback']:
-        pass
-
-    def _get_single_indexed_playback(self) -> Optional['WaveformPlayback']:
-        """Returns None if there is no or if there are more than one indexed playbacks"""
-        # detect if there is only a single indexed playback
-        single_indexed_playback = None
-        for playback in self.iter_waveform_playbacks():
-            if not playback.shared:
-                if single_indexed_playback is None:
-                    single_indexed_playback = playback
-                else:
-                    break
-        else:
-            return single_indexed_playback
-        return None
-
-    @abc.abstractmethod
-    def _visit_nodes(self, waveform_manager: ProgramWaveformManager):
-        """push all concatenated waveforms in the waveform manager"""
-
-    @abc.abstractmethod
-    def to_source_code(self, waveform_manager: ProgramWaveformManager, node_name_generator: Iterator[str], line_prefix: str, pos_var_name: str,
-                        advance_pos_var: bool = True):
-        """besides creating the source code, this function registers all needed waveforms to the program manager
-        1. shared waveforms
-        2. concatenated waveforms in the correct order
-
-        Args:
-            waveform_manager:
-            node_name_generator: generates unique names of nodes
-            line_prefix:
-            pos_var_name:
-            advance_pos_var: Indexed playback will not advance the position if set to False. This is used internally
-            to optimize repeat statements with a single indexed playback.
-        Returns:
-
-        """
-
-    def __eq__(self, other):
-        """Compare objects based on __slots__"""
-        assert getattr(self, '__dict__', None) is None
-        return type(self) == type(other) and all(getattr(self, attr) == getattr(other, attr)
-                                                 for base_class in inspect.getmro(type(self))
-                                                 for attr in getattr(base_class, '__slots__', ()))
-
-
-class Scope(SEQCNode):
-    """Sequence of nodes"""
-
-    __slots__ = ('nodes',)
-
-    def __init__(self, nodes: Sequence[SEQCNode] = ()):
-        self.nodes = list(nodes)
-
-    def samples(self):
-        return sum(node.samples() for node in self.nodes)
-
-    def iter_waveform_playbacks(self) -> Iterator['WaveformPlayback']:
-        for node in self.nodes:
-            yield from node.iter_waveform_playbacks()
-
-    def stepping_hash(self) -> int:
-        return functools.reduce(int.__xor__, (node.stepping_hash() for node in self.nodes), hash(type(self)))
-
-    def same_stepping(self, other: 'Scope'):
-        return (type(other) is Scope and
-                len(self.nodes) == len(other.nodes) and
-                all(n1.same_stepping(n2) for n1, n2 in zip(self.nodes, other.nodes)))
-
-    def _visit_nodes(self, waveform_manager: ProgramWaveformManager):
-        for node in self.nodes:
-            node._visit_nodes(waveform_manager)
-
-    def to_source_code(self, waveform_manager: ProgramWaveformManager, node_name_generator: Iterator[str],
-                       line_prefix: str, pos_var_name: str,
-                       advance_pos_var: bool = True):
-        for node in self.nodes:
-            yield from node.to_source_code(waveform_manager,
-                                           line_prefix=line_prefix,
-                                           pos_var_name=pos_var_name,
-                                           node_name_generator=node_name_generator,
-                                           advance_pos_var=advance_pos_var)
-
-    def __eq__(self, other):
-        if type(other) is type(self):
-            return self.nodes == other.nodes
-        else:
-            return NotImplemented
-
-    def __repr__(self):
-        return f"Scope(nodes={self.nodes!r})"
-
-
-class Repeat(SEQCNode):
-    """"""
-    __slots__ = ('repetition_count', 'scope')
-    INITIAL_POSITION_NAME_TEMPLATE = 'init_pos_{node_name}'
-    FOR_LOOP_NAME_TEMPLATE = 'idx_{node_name}'
-
-    class _AdvanceStrategy:
-        """describes what happens how this node interacts with the position variable"""
-        INITIAL_RESET = 'initial_reset'
-        POST_ADVANCE = 'post_advance'
-        IGNORE = 'ignore'
-
-    def __init__(self, repetition_count: Union[int, str], scope: SEQCNode):
-        """
-        Args:
-            repetition_count: A const integer value or a string that is expected to be a "var"
-            scope: The repeated scope
-        """
-        if isinstance(repetition_count, int):
-            assert repetition_count > 1
-        else:
-            assert isinstance(repetition_count, str) and repetition_count.isidentifier()
-
-        self.repetition_count = repetition_count
-        self.scope = scope
-
-    def samples(self):
-        return self.scope.samples()
-
-    def same_stepping(self, other: 'Repeat'):
-        return (type(self) == type(other) and
-                self.repetition_count == other.repetition_count and
-                self.scope.same_stepping(other.scope))
-
-    def stepping_hash(self) -> int:
-        return hash((type(self), self.repetition_count, self.scope.stepping_hash()))
-
-    def iter_waveform_playbacks(self) -> Iterator['WaveformPlayback']:
-        return self.scope.iter_waveform_playbacks()
-
-    def _visit_nodes(self, waveform_manager: ProgramWaveformManager):
-        self.scope._visit_nodes(waveform_manager)
-
-    def _get_position_advance_strategy(self):
-        """Deduct the optimal position advance strategy:
-
-        There is more than one indexed playback -> position needs to be advanced during each iteration and set back to
-        initial value at the begin of each new iteration
-        There is exactly one indexed playback -> The position is not advanced in the body but needs to be advanced after
-        all repetitions are done
-        There is no indexed playback -> We do not care about the position at all
-        """
-        self_samples = self.samples()
-        if self_samples > 0:
-            single_playback = self.scope._get_single_indexed_playback()
-            if single_playback is None or single_playback.samples() != self_samples:
-                # TODO: I am not sure whether the 'single_playback.samples() != self_samples' is necessary
-                # there is more than one indexed playback
-                return self._AdvanceStrategy.INITIAL_RESET
-            else:
-                # there is only a single indexed playback
-                return self._AdvanceStrategy.POST_ADVANCE
-        else:
-            # there is no indexed playback
-            return self._AdvanceStrategy.IGNORE
-
-    def to_source_code(self, waveform_manager: ProgramWaveformManager, node_name_generator: Iterator[str],
-                       line_prefix: str, pos_var_name: str, advance_pos_var: bool = True):
-        body_prefix = line_prefix + self.INDENTATION
-
-        advance_strategy = self._get_position_advance_strategy() if advance_pos_var else self._AdvanceStrategy.IGNORE
-        inner_advance_pos_var = advance_strategy == self._AdvanceStrategy.INITIAL_RESET
-
-        def get_node_name():
-            """Helper to assert node name only generated when needed and only generated once"""
-            if getattr(get_node_name, 'node_name', None) is None:
-                get_node_name.node_name = next(node_name_generator)
-            return get_node_name.node_name
-
-        if advance_strategy == self._AdvanceStrategy.INITIAL_RESET:
-            initial_position_name = self.INITIAL_POSITION_NAME_TEMPLATE.format(node_name=get_node_name())
-
-            # store initial position
-            yield '{line_prefix}var {init_pos_name} = {pos_var_name};'.format(line_prefix=line_prefix,
-                                                                              init_pos_name=initial_position_name,
-                                                                              pos_var_name=pos_var_name)
-
-        if isinstance(self.repetition_count, int):
-            yield '{line_prefix}repeat({repetition_count}) {{'.format(line_prefix=line_prefix,
-                                                                      repetition_count=self.repetition_count)
-        else:
-            # repeat requires a const-expression so we need to use a for loop for user reg vars
-            assert isinstance(self.repetition_count, str)
-            loop_var = self.FOR_LOOP_NAME_TEMPLATE.format(node_name=get_node_name())
-            yield '{line_prefix}var {loop_var};'.format(line_prefix=line_prefix, loop_var=loop_var)
-            yield ('{line_prefix}for({loop_var} = 0; '
-                   '{loop_var} < {repetition_count}; '
-                   '{loop_var} = {loop_var} + 1) {{').format(line_prefix=line_prefix,
-                                                             loop_var=loop_var,
-                                                             repetition_count=self.repetition_count)
-
-        if advance_strategy == self._AdvanceStrategy.INITIAL_RESET:
-            yield ('{body_prefix}{pos_var_name} = {init_pos_name};'
-                   '').format(body_prefix=body_prefix,
-                              pos_var_name=pos_var_name,
-                              init_pos_name=initial_position_name)
-        yield from self.scope.to_source_code(waveform_manager,
-                                             line_prefix=body_prefix, pos_var_name=pos_var_name,
-                                             node_name_generator=node_name_generator,
-                                             advance_pos_var=inner_advance_pos_var)
-        yield '{line_prefix}}}'.format(line_prefix=line_prefix)
-
-        if advance_strategy == self._AdvanceStrategy.POST_ADVANCE:
-            yield '{line_prefix}{pos_var_name} = {pos_var_name} + {samples};'.format(line_prefix=line_prefix,
-                                                                                     pos_var_name=pos_var_name,
-                                                                                     samples=self.samples())
-
-
-class SteppingRepeat(SEQCNode):
-    STEPPING_REPEAT_COMMENT = ' // stepping repeat'
-    __slots__ = ('node_cluster',)
-
-    def __init__(self, node_cluster: Sequence[SEQCNode]):
-        self.node_cluster = node_cluster
-
-    def samples(self) -> int:
-        return self.repetition_count * self.node_cluster[0].samples()
-
-    @property
-    def repetition_count(self):
-        return len(self.node_cluster)
-
-    def iter_waveform_playbacks(self) -> Iterator['WaveformPlayback']:
-        for node in self.node_cluster:
-            yield from node.iter_waveform_playbacks()
-
-    def stepping_hash(self) -> int:
-        return hash((type(self), self.node_cluster[0].stepping_hash()))
-
-    def same_stepping(self, other: 'SteppingRepeat'):
-        return (type(other) is SteppingRepeat and
-                len(self.node_cluster) == len(other.node_cluster) and
-                self.node_cluster[0].same_stepping(other.node_cluster[0]))
-
-    def _visit_nodes(self, waveform_manager: ProgramWaveformManager):
-        for node in self.node_cluster:
-            node._visit_nodes(waveform_manager)
-
-    def to_source_code(self, waveform_manager: ProgramWaveformManager, node_name_generator: Iterator[str],
-                       line_prefix: str, pos_var_name: str,
-                       advance_pos_var: bool = True):
-        body_prefix = line_prefix + self.INDENTATION
-        repeat_open = '{line_prefix}repeat({repetition_count}) {{' + self.STEPPING_REPEAT_COMMENT
-        yield repeat_open.format(line_prefix=line_prefix,
-                                 repetition_count=self.repetition_count)
-        yield from self.node_cluster[0].to_source_code(waveform_manager,
-                                                       line_prefix=body_prefix, pos_var_name=pos_var_name,
-                                                       node_name_generator=node_name_generator,
-                                                       advance_pos_var=advance_pos_var)
-
-        # register remaining concatenated waveforms
-        for node in itertools.islice(self.node_cluster, 1, None):
-            node._visit_nodes(waveform_manager)
-
-        yield '{line_prefix}}}'.format(line_prefix=line_prefix)
-
-
-class WaveformPlayback(SEQCNode):
-    ADVANCE_DISABLED_COMMENT = ' // advance disabled do to parent repetition'
-    ENABLE_DYNAMIC_RATE_REDUCTION = False
-
-    __slots__ = ('waveform', 'shared', 'rate')
-
-    def __init__(self, waveform: Tuple[BinaryWaveform, ...], shared: bool = False, rate: int = None):
-        assert isinstance(waveform, tuple)
-        if self.ENABLE_DYNAMIC_RATE_REDUCTION and rate is None:
-            for wf in waveform:
-                rate = wf.dynamic_rate(12 if rate is None else rate)
-        self.waveform = waveform
-        self.shared = shared
-        self.rate = rate
-
-    def __repr__(self):
-        return f"WaveformPlayback(<{id(self)}>)"
-
-    def samples(self) -> int:
-        """Samples consumed in the big concatenated waveform"""
-        if self.shared:
-            return 0
-        else:
-            wf_lens = set(map(len, self.waveform))
-            assert len(wf_lens) == 1
-            wf_len, = wf_lens
-            if self.rate is not None:
-                wf_len //= (1 << self.rate)
-            return wf_len
-
-    def rate_reduced_waveform(self) -> Tuple[BinaryWaveform]:
-        if self.rate is None:
-            return self.waveform
-        else:
-            return tuple(BinaryWaveform(wf.data.reshape((-1, 3))[::(1 << self.rate), :].ravel())
-                         for wf in self.waveform)
-
-    def stepping_hash(self) -> int:
-        if self.shared:
-            return hash((type(self), self.waveform))
-        else:
-            return hash((type(self), self.samples()))
-
-    def same_stepping(self, other: 'WaveformPlayback') -> bool:
-        same_type = type(self) is type(other) and self.shared == other.shared
-        if self.shared:
-            return same_type and self.rate == other.rate and self.waveform == other.waveform
-        else:
-            return same_type and self.samples() == other.samples()
-
-    def iter_waveform_playbacks(self) -> Iterator['WaveformPlayback']:
-        yield self
-
-    def _visit_nodes(self, waveform_manager: ProgramWaveformManager):
-        if not self.shared:
-            waveform_manager.request_concatenated(self.rate_reduced_waveform())
-
-    def to_source_code(self, waveform_manager: ProgramWaveformManager,
-                       node_name_generator: Iterator[str], line_prefix: str, pos_var_name: str,
-                       advance_pos_var: bool = True):
-        rate_adjustment = "" if self.rate is None else f", {self.rate}"
-        if self.shared:
-            yield f'{line_prefix}playWave(' \
-                  f'{waveform_manager.request_shared(self.rate_reduced_waveform())}' \
-                  f'{rate_adjustment});'
-        else:
-            wf_name = waveform_manager.request_concatenated(self.rate_reduced_waveform())
-            wf_len = self.samples()
-            play_cmd = f'{line_prefix}playWaveIndexed({wf_name}, {pos_var_name}, {wf_len}{rate_adjustment});'
-
-            if advance_pos_var:
-                advance_cmd = f' {pos_var_name} = {pos_var_name} + {wf_len};'
-            else:
-                advance_cmd = self.ADVANCE_DISABLED_COMMENT
-            yield play_cmd + advance_cmd
-
-
-_PROGRAM_SELECTION_BLOCK = """\
-while (true) {{
-  // read program selection value
-  prog_sel = getUserReg(PROG_SEL_REGISTER);
-  
-  // calculate value to write back to PROG_SEL_REGISTER
-  new_prog_sel = prog_sel | playback_finished;
-  if (!(prog_sel & NO_RESET_MASK)) new_prog_sel &= INVERTED_PROG_SEL_MASK;
-  setUserReg(PROG_SEL_REGISTER, new_prog_sel);
-  
-  // reset playback flag
-  playback_finished = 0;
-  
-  // only use part of prog sel that does not mean other things to select the program.
-  prog_sel &= PROG_SEL_MASK;
-  
-  switch (prog_sel) {{
-{program_cases}
-    default:
-      wait(IDLE_WAIT_CYCLES);
-  }}
-}}"""
-
-_PROGRAM_SELECTION_CASE = """\
-    case {selection_index}:
-      {program_function_name}();
-      waitWave();
-      playback_finished = PLAYBACK_FINISHED_MASK;"""
-
-
-def _make_program_selection_block(programs: Iterable[Tuple[int, str]]):
-    program_cases = []
-    for selection_index, program_function_name in programs:
-        program_cases.append(_PROGRAM_SELECTION_CASE.format(selection_index=selection_index,
-                                                            program_function_name=program_function_name))
-    return _PROGRAM_SELECTION_BLOCK.format(program_cases="\n".join(program_cases))
diff --git a/qupulse/_program/tabor.py b/qupulse/_program/tabor.py
index 64002d969..78f2c4f93 100644
--- a/qupulse/_program/tabor.py
+++ b/qupulse/_program/tabor.py
@@ -1,5 +1,7 @@
+import dataclasses
 import sys
-from typing import NamedTuple, Optional, List, Generator, Tuple, Sequence, Mapping, Union, Dict, FrozenSet, cast
+from typing import NamedTuple, Optional, List, Generator, Tuple, Sequence, Mapping, Union, Dict, FrozenSet, cast,\
+    Hashable
 from enum import Enum
 import operator
 from collections import OrderedDict
@@ -10,10 +12,10 @@
 
 from qupulse.utils.types import ChannelID, TimeType
 from qupulse.hardware.awgs.base import ProgramEntry
-from qupulse.hardware.util import get_sample_times, voltage_to_uint16
-from qupulse._program.waveforms import Waveform
-from qupulse._program._loop import Loop
-from qupulse._program.volatile import VolatileRepetitionCount, VolatileProperty
+from qupulse.hardware.util import get_sample_times, voltage_to_uint16, find_positions
+from qupulse.program.waveforms import Waveform
+from qupulse.program.loop import Loop
+from qupulse.program.volatile import VolatileRepetitionCount, VolatileProperty
 
 assert(sys.byteorder == 'little')
 
@@ -406,7 +408,7 @@ def __init__(self,
             else:
                 mode = TaborSequencing.SINGLE
 
-        super().__init__(loop=program,
+        super().__init__(program=program,
                          channels=channels,
                          markers=markers,
                          amplitudes=amplitudes,
@@ -428,7 +430,7 @@ def __init__(self,
         else:
             self.setup_advanced_sequence_mode()
 
-        self._sampled_segments = self._calc_sampled_segments()
+        self._sampled_segments, self._waveform_to_segment = self._calc_sampled_segments()
 
     @property
     def markers(self) -> Tuple[Optional[ChannelID], Optional[ChannelID]]:
@@ -462,19 +464,22 @@ def _marker_data(self, waveform: Waveform, time: np.ndarray, marker_idx: int):
             marker = self._markers[marker_idx]
             return waveform.get_sampled(channel=marker, sample_times=time) != 0
 
-    def _calc_sampled_segments(self) -> Tuple[Sequence[TaborSegment], Sequence[int]]:
+    def _calc_sampled_segments(self) -> Tuple[Tuple[Sequence[TaborSegment], Sequence[int]], Sequence[int]]:
         """
         Returns:
-            (segments, segment_lengths)
+            ((segments, segment_lengths), waveform_to_segment)
         """
-        time_array, segment_lengths = get_sample_times(self._parsed_program.waveforms, self._sample_rate)
+        time_array, waveform_samples = get_sample_times(self._parsed_program.waveforms, self._sample_rate)
 
-        if np.any(segment_lengths % 16 > 0) or np.any(segment_lengths < 192):
+        if np.any(waveform_samples % 16 > 0) or np.any(waveform_samples < 192):
             raise TaborException('At least one waveform has a length that is smaller 192 or not a multiple of 16')
 
-        segments = []
-        for i, waveform in enumerate(self._parsed_program.waveforms):
-            t = time_array[:segment_lengths[i]]
+        segments: Dict[TaborSegment, int] = {}
+        segment_lengths = []
+
+        waveform_to_segment = []
+        for waveform, n_samples in zip(self._parsed_program.waveforms, waveform_samples):
+            t = time_array[:n_samples]
             marker_time = t[::2]
             segment_a = self._channel_data(waveform, t, 0)
             segment_b = self._channel_data(waveform, t, 1)
@@ -486,8 +491,12 @@ def _calc_sampled_segments(self) -> Tuple[Sequence[TaborSegment], Sequence[int]]
                                    ch_b=segment_b,
                                    marker_a=marker_a,
                                    marker_b=marker_b)
-            segments.append(segment)
-        return segments, segment_lengths
+            previous_segment_count = len(segments)
+            segment_idx = segments.setdefault(segment, previous_segment_count)
+            waveform_to_segment.append(segment_idx)
+            if segment_idx == previous_segment_count:
+                segment_lengths.append(n_samples)
+        return (list(segments.keys()), np.array(segment_lengths, dtype=np.uint64)), waveform_to_segment
 
     def setup_single_sequence_mode(self) -> None:
         assert self.program.depth() == 1
@@ -554,10 +563,13 @@ def update_volatile_parameters(self, parameters: Mapping[str, numbers.Number]) -
 
         return modifications
 
-    def get_sequencer_tables(self):  # -> List[List[TableDescription, Optional[MappedParameter]]]:
-        return self._parsed_program.sequencer_tables
+    def get_sequencer_tables(self) -> Sequence[Sequence[Tuple[TableDescription, Optional[VolatileProperty]]]]:
+        wf_to_seq = self._waveform_to_segment
+        return [[(TableDescription(rep_count, wf_to_seq[elem_idx], jump), volatile)
+                for (rep_count, elem_idx, jump), volatile in sequencer_table]
+                for sequencer_table in self._parsed_program.sequencer_tables]
 
-    def get_advanced_sequencer_table(self) -> List[TableEntry]:
+    def get_advanced_sequencer_table(self) -> Sequence[TableEntry]:
         """Advanced sequencer table that can be used  via the download_adv_seq_table pytabor command"""
         return self._parsed_program.advanced_sequencer_table
 
@@ -644,12 +656,12 @@ def prepare_program_for_advanced_sequence_mode(program: Loop, min_seq_len: int,
             i += 1
 
 
-ParsedProgram = NamedTuple('ParsedProgram', [('advanced_sequencer_table', Sequence[TableEntry]),
-                                             ('sequencer_tables', Sequence[Sequence[
-                                                     Tuple[TableDescription, Optional[VolatileProperty]]]]),
-                                             ('waveforms', Tuple[Waveform, ...]),
-                                             ('volatile_parameter_positions', Dict[Union[int, Tuple[int, int]],
-                                                                                   VolatileRepetitionCount])])
+@dataclasses.dataclass
+class ParsedProgram:
+    advanced_sequencer_table: Sequence[TableEntry]
+    sequencer_tables: Sequence[Sequence[Tuple[TableDescription, Optional[VolatileProperty]]]]
+    waveforms: Tuple[Waveform, ...]
+    volatile_parameter_positions: Dict[Union[int, Tuple[int, int]], VolatileRepetitionCount]
 
 
 def parse_aseq_program(program: Loop, used_channels: FrozenSet[ChannelID]) -> ParsedProgram:
@@ -726,3 +738,91 @@ def parse_single_seq_program(program: Loop, used_channels: FrozenSet[ChannelID])
         waveforms=tuple(waveforms.keys()),
         volatile_parameter_positions=volatile_parameter_positions
     )
+
+
+def find_place_for_segments_in_memory(
+        current_segment_hashes: np.ndarray,
+        current_segment_references: np.ndarray,
+        current_segment_capacities: np.ndarray,
+        total_capacity: int,
+        new_segment_hashes: Sequence[int],
+        new_segment_lengths: Sequence[int]) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
+    """
+    1. Find known segments
+    2. Find empty spaces with fitting length
+    3. Find empty spaces with bigger length
+    4. Amend remaining segments
+    :param segments:
+    :param segment_lengths:
+    :return:
+    """
+    new_segment_hashes = np.asarray(new_segment_hashes)
+    new_segment_lengths = np.asarray(new_segment_lengths)
+
+    waveform_to_segment = find_positions(current_segment_hashes, new_segment_hashes)
+
+    # separate into known and unknown
+    unknown = waveform_to_segment == -1
+    known = ~unknown
+
+    known_pos_in_memory = waveform_to_segment[known]
+
+    assert len(known_pos_in_memory) == 0 or np.all(current_segment_hashes[known_pos_in_memory] == new_segment_hashes[known])
+
+    new_reference_counter = current_segment_references.copy()
+    new_reference_counter[known_pos_in_memory] += 1
+
+    to_upload_size = np.sum(new_segment_lengths[unknown] + 16)
+    free_points_in_total = total_capacity - np.sum(current_segment_capacities[current_segment_references > 0])
+    if free_points_in_total < to_upload_size:
+        raise RuntimeError(f'Not enough free memory. Required {to_upload_size}. Available: {free_points_in_total}')
+
+    to_amend = cast(np.ndarray, unknown)
+    to_insert = np.full(len(new_segment_hashes), fill_value=-1, dtype=np.int64)
+
+    reserved_indices = np.flatnonzero(new_reference_counter > 0)
+    first_free = reserved_indices[-1] + 1 if len(reserved_indices) else 0
+
+    free_segments = new_reference_counter[:first_free] == 0
+    free_segment_count = np.sum(free_segments)
+
+    # look for a free segment place with the same length
+    for segment_idx in np.flatnonzero(to_amend):
+        if free_segment_count == 0:
+            break
+
+        pos_of_same_length = np.logical_and(free_segments,
+                                            new_segment_lengths[segment_idx] == current_segment_capacities[:first_free])
+        idx_same_length = np.argmax(pos_of_same_length)
+        if pos_of_same_length[idx_same_length]:
+            free_segments[idx_same_length] = False
+            free_segment_count -= 1
+
+            to_amend[segment_idx] = False
+            to_insert[segment_idx] = idx_same_length
+
+    # try to find places that are larger than the segments to fit in starting with the large segments and large
+    # free spaces
+    segment_indices = np.flatnonzero(to_amend)[np.argsort(new_segment_lengths[to_amend], kind='stable')[::-1]]
+    capacities = current_segment_capacities[:first_free]
+    for segment_idx in segment_indices:
+        free_capacities = capacities[free_segments]
+        free_segments_indices = np.flatnonzero(free_segments)[np.argsort(free_capacities, kind='stable')[::-1]]
+
+        if len(free_segments_indices) == 0:
+            break
+
+        fitting_segment = np.argmax((free_capacities >= new_segment_lengths[segment_idx])[::-1])
+        fitting_segment = free_segments_indices[fitting_segment]
+        if current_segment_capacities[fitting_segment] >= new_segment_lengths[segment_idx]:
+            free_segments[fitting_segment] = False
+            to_amend[segment_idx] = False
+            to_insert[segment_idx] = fitting_segment
+
+    free_points_at_end = total_capacity - np.sum(current_segment_capacities[:first_free])
+    if np.sum(new_segment_lengths[to_amend] + 16) > free_points_at_end:
+        raise RuntimeError('Fragmentation does not allow upload.',
+                           np.sum(new_segment_lengths[to_amend] + 16),
+                           free_points_at_end)
+
+    return waveform_to_segment, to_amend, to_insert
diff --git a/qupulse/_program/transformation.py b/qupulse/_program/transformation.py
index 66a1641f9..c6a0d0def 100644
--- a/qupulse/_program/transformation.py
+++ b/qupulse/_program/transformation.py
@@ -1,400 +1,7 @@
-from typing import Any, Mapping, Set, Tuple, Sequence, AbstractSet, Union, TYPE_CHECKING, Hashable
-from abc import abstractmethod
-from numbers import Real
+from qupulse.program.transformation import *
 
-import numpy as np
+import qupulse.program.transformation
 
-from qupulse import ChannelID
-from qupulse.comparable import Comparable
-from qupulse.utils.types import SingletonABCMeta, frozendict
-from qupulse.expressions import ExpressionScalar
+__all__ = qupulse.program.transformation.__all__
 
-
-_TrafoValue = Union[Real, ExpressionScalar]
-
-
-class Transformation(Comparable):
-    _identity_singleton = None
-    """Transforms numeric time-voltage values for multiple channels to other time-voltage values. The number and names
-     of input and output channels might differ."""
-
-    @abstractmethod
-    def __call__(self, time: Union[np.ndarray, float],
-                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
-        """Apply transformation to data
-        Args:
-            time:
-            data:
-
-        Returns:
-            transformed: A DataFrame that has been transformed with index == output_channels
-        """
-
-    @abstractmethod
-    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        """Return the channel identifiers"""
-
-    @abstractmethod
-    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        """Channels that are required for getting data for the requested output channel"""
-
-    def chain(self, next_transformation: 'Transformation') -> 'Transformation':
-        if next_transformation is IdentityTransformation():
-            return self
-        else:
-            return chain_transformations(self, next_transformation)
-
-    def is_constant_invariant(self):
-        """Signals if the transformation always maps constants to constants."""
-        return False
-
-    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return frozenset()
-
-
-class IdentityTransformation(Transformation, metaclass=SingletonABCMeta):
-    def __call__(self, time: Union[np.ndarray, float],
-                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
-        return data
-
-    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return input_channels
-
-    @property
-    def compare_key(self) -> None:
-        return None
-
-    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return output_channels
-
-    def chain(self, next_transformation: Transformation) -> Transformation:
-        return next_transformation
-
-    def __repr__(self):
-        return 'IdentityTransformation()'
-
-    def is_constant_invariant(self):
-        """Signals if the transformation always maps constants to constants."""
-        return True
-
-    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return input_channels
-
-
-class ChainedTransformation(Transformation):
-    def __init__(self, *transformations: Transformation):
-        self._transformations = transformations
-
-    @property
-    def transformations(self) -> Tuple[Transformation, ...]:
-        return self._transformations
-
-    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        for transformation in self._transformations:
-            input_channels = transformation.get_output_channels(input_channels)
-        return input_channels
-
-    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        for transformation in reversed(self._transformations):
-            output_channels = transformation.get_input_channels(output_channels)
-        return output_channels
-
-    def __call__(self, time: Union[np.ndarray, float],
-                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
-        for transformation in self._transformations:
-            data = transformation(time, data)
-        return data
-
-    @property
-    def compare_key(self) -> Tuple[Transformation, ...]:
-        return self._transformations
-
-    def chain(self, next_transformation) -> Transformation:
-        return chain_transformations(*self.transformations, next_transformation)
-
-    def __repr__(self):
-        return f'{type(self).__name__}{self._transformations!r}'
-
-    def is_constant_invariant(self):
-        """Signals if the transformation always maps constants to constants."""
-        return all(trafo.is_constant_invariant() for trafo in self._transformations)
-
-    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        for trafo in self._transformations:
-            input_channels = trafo.get_constant_output_channels(input_channels)
-        return input_channels
-
-
-class LinearTransformation(Transformation):
-    def __init__(self,
-                 transformation_matrix: np.ndarray,
-                 input_channels: Sequence[ChannelID],
-                 output_channels: Sequence[ChannelID]):
-        """
-
-        Args:
-            transformation_matrix: Matrix describing the transformation with shape (output_channels, input_channels)
-            input_channels: Channel ids of the columns
-            output_channels: Channel ids of the rows
-        """
-        transformation_matrix = np.asarray(transformation_matrix)
-
-        if transformation_matrix.shape != (len(output_channels), len(input_channels)):
-            raise ValueError('Shape of transformation matrix does not match to the given channels')
-
-        output_sorter = np.argsort(output_channels)
-        transformation_matrix = transformation_matrix[output_sorter, :]
-
-        input_sorter = np.argsort(input_channels)
-        transformation_matrix = transformation_matrix[:, input_sorter]
-
-        self._matrix = transformation_matrix
-        self._input_channels = tuple(sorted(input_channels))
-        self._output_channels = tuple(sorted(output_channels))
-        self._input_channels_set = frozenset(self._input_channels)
-        self._output_channels_set = frozenset(self._output_channels)
-
-    def __call__(self, time: Union[np.ndarray, float],
-                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
-        data_out = {forwarded_channel: data[forwarded_channel]
-                    for forwarded_channel in set(data.keys()).difference(self._input_channels)}
-
-        if len(data_out) == len(data):
-            # only forwarded data
-            return data_out
-
-        try:
-            data_in = np.stack([data[in_channel] for in_channel in self._input_channels])
-        except KeyError as error:
-            raise KeyError('Invalid input channels', set(data.keys()), set(self._input_channels)) from error
-
-        transformed_data = self._matrix @ data_in
-
-        for idx, out_channel in enumerate(self._output_channels):
-            data_out[out_channel] = transformed_data[idx, ...]
-
-        return data_out
-
-    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        if not input_channels >= self._input_channels_set:
-            # input_channels is not a superset of the required input channels
-            raise KeyError('Invalid input channels', input_channels, self._input_channels_set)
-
-        return (input_channels - self._input_channels_set) | self._output_channels_set
-
-    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        forwarded = output_channels - self._output_channels_set
-        if not forwarded.isdisjoint(self._input_channels):
-            raise KeyError('Is input channel', forwarded & self._input_channels_set)
-        elif output_channels.isdisjoint(self._output_channels):
-            return output_channels
-        else:
-            return forwarded | self._input_channels_set
-
-    @property
-    def compare_key(self) -> Tuple[Tuple[ChannelID], Tuple[ChannelID], bytes]:
-        return self._input_channels, self._output_channels, self._matrix.tobytes()
-
-    def __repr__(self):
-        return ('LinearTransformation('
-                'transformation_matrix={transformation_matrix},'
-                'input_channels={input_channels},'
-                'output_channels={output_channels})').format(transformation_matrix=self._matrix.tolist(),
-                                                             input_channels=self._input_channels,
-                                                             output_channels=self._output_channels)
-
-    def is_constant_invariant(self):
-        """Signals if the transformation always maps constants to constants."""
-        return True
-
-    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return input_channels
-
-
-class OffsetTransformation(Transformation):
-    def __init__(self, offsets: Mapping[ChannelID, _TrafoValue]):
-        """Adds an offset to each channel specified in offsets.
-
-        Channels not in offsets are forewarded
-
-        Args:
-            offsets: Channel -> offset mapping
-        """
-        self._offsets = frozendict(offsets)
-        assert _are_valid_transformation_expressions(self._offsets), f"Not valid transformation expressions: {self._offsets}"
-
-    def __call__(self, time: Union[np.ndarray, float],
-                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
-        offsets = _instantiate_expression_dict(time, self._offsets)
-        return {channel: channel_values + offsets[channel] if channel in offsets else channel_values
-                for channel, channel_values in data.items()}
-
-    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return output_channels
-
-    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return input_channels
-
-    @property
-    def compare_key(self) -> Hashable:
-        return self._offsets
-
-    def __repr__(self):
-        return f'{type(self).__name__}({dict(self._offsets)!r})'
-
-    def is_constant_invariant(self):
-        """Signals if the transformation always maps constants to constants."""
-        return not _has_time_dependent_values(self._offsets)
-
-    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return _get_constant_output_channels(self._offsets, input_channels)
-
-
-class ScalingTransformation(Transformation):
-    def __init__(self, factors: Mapping[ChannelID, _TrafoValue]):
-        self._factors = frozendict(factors)
-        assert _are_valid_transformation_expressions(self._factors), f"Not valid transformation expressions: {self._factors}"
-
-    def __call__(self, time: Union[np.ndarray, float],
-                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
-        factors = _instantiate_expression_dict(time, self._factors)
-        return {channel: channel_values * factors[channel] if channel in factors else channel_values
-                for channel, channel_values in data.items()}
-
-    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return output_channels
-
-    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return input_channels
-
-    @property
-    def compare_key(self) -> Hashable:
-        return self._factors
-
-    def __repr__(self):
-        return f'{type(self).__name__}({dict(self._factors)!r})'
-
-    def is_constant_invariant(self):
-        """Signals if the transformation always maps constants to constants."""
-        return not _has_time_dependent_values(self._factors)
-
-    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return _get_constant_output_channels(self._factors, input_channels)
-
-
-try:
-    if TYPE_CHECKING:
-        import pandas
-        PandasDataFrameType = pandas.DataFrame
-    else:
-        PandasDataFrameType = Any
-
-    def linear_transformation_from_pandas(transformation: PandasDataFrameType) -> LinearTransformation:
-        """ Creates a LinearTransformation object out of a pandas data frame.
-
-        Args:
-            transformation (pandas.DataFrame): The pandas.DataFrame object out of which a LinearTransformation will be formed.
-
-        Returns:
-            the created LinearTransformation instance
-        """
-        return LinearTransformation(transformation.values, transformation.columns, transformation.index)
-
-    LinearTransformation.from_pandas = linear_transformation_from_pandas
-except ImportError:
-    pass
-
-
-class ParallelChannelTransformation(Transformation):
-    def __init__(self, channels: Mapping[ChannelID, _TrafoValue]):
-        """Set channel values to given values regardless their former existence. The values can be time dependent
-        expressions.
-
-        Args:
-            channels: Channels present in this map are set to the given value.
-        """
-        self._channels: Mapping[ChannelID, _TrafoValue] = frozendict(channels.items())
-        assert _are_valid_transformation_expressions(self._channels), f"Not valid transformation expressions: {self._channels}"
-
-    def __call__(self, time: Union[np.ndarray, float],
-                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
-        overwritten = self._instantiated_values(time)
-        return {**data, **overwritten}
-
-    def _instantiated_values(self, time):
-        scope = {'t': time}
-        return {channel: value.evaluate_in_scope(scope) if hasattr(value, 'evaluate_in_scope') else np.full_like(time, fill_value=value, dtype=float)
-                for channel, value in self._channels.items()}
-
-    @property
-    def compare_key(self) -> Hashable:
-        return self._channels
-
-    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return output_channels - self._channels.keys()
-
-    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        return input_channels | self._channels.keys()
-
-    def __repr__(self):
-        return f'{type(self).__name__}({dict(self._channels)!r})'
-
-    def is_constant_invariant(self):
-        """Signals if the transformation always maps constants to constants."""
-        return not _has_time_dependent_values(self._channels)
-
-    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-        output_channels = set(input_channels)
-        for ch, value in self._channels.items():
-            if hasattr(value, 'variables'):
-                output_channels.discard(ch)
-            else:
-                output_channels.add(ch)
-
-        return output_channels
-
-
-def chain_transformations(*transformations: Transformation) -> Transformation:
-    parsed_transformations = []
-    for transformation in transformations:
-        if transformation is IdentityTransformation() or transformation is None:
-            pass
-        elif isinstance(transformation, ChainedTransformation):
-            parsed_transformations.extend(transformation.transformations)
-        else:
-            parsed_transformations.append(transformation)
-    if len(parsed_transformations) == 0:
-        return IdentityTransformation()
-    elif len(parsed_transformations) == 1:
-        return parsed_transformations[0]
-    else:
-        return ChainedTransformation(*parsed_transformations)
-
-
-def _instantiate_expression_dict(time, expressions: Mapping[str, _TrafoValue]) -> Mapping[str, Union[Real, np.ndarray]]:
-    scope = {'t': time}
-    modified_expressions = {}
-    for name, value in expressions.items():
-        if hasattr(value, 'evaluate_in_scope'):
-            modified_expressions[name] = value.evaluate_in_scope(scope)
-    if modified_expressions:
-        return {**expressions, **modified_expressions}
-    else:
-        return expressions
-
-
-def _has_time_dependent_values(expressions: Mapping[ChannelID, _TrafoValue]) -> bool:
-    return any(hasattr(value, 'variables')
-               for value in expressions.values())
-
-
-def _get_constant_output_channels(expressions: Mapping[ChannelID, _TrafoValue],
-                                  constant_input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
-    return {ch
-            for ch in constant_input_channels
-            if not hasattr(expressions.get(ch, None), 'variables')}
-
-def _are_valid_transformation_expressions(expressions: Mapping[ChannelID, _TrafoValue]) -> bool:
-    return all(expr.variables == ('t',)
-               for expr in expressions.values()
-               if hasattr(expr, 'variables'))
+del qupulse
diff --git a/qupulse/_program/volatile.py b/qupulse/_program/volatile.py
index ab76e8ecc..ddfe2aa16 100644
--- a/qupulse/_program/volatile.py
+++ b/qupulse/_program/volatile.py
@@ -1,70 +1,7 @@
-from typing import NamedTuple, Mapping
-import warnings
-import numbers
+from qupulse.program.volatile import *
 
+import qupulse.program.volatile
 
-from qupulse.parameter_scope import Scope, MappedScope, JointScope
-from qupulse.expressions import Expression, ExpressionScalar
-from qupulse.utils.types import FrozenDict, FrozenMapping
-from qupulse.utils import is_integer
+__all__ = qupulse.program.volatile.__all__
 
-
-VolatileProperty = NamedTuple('VolatileProperty', [('expression', Expression),
-                                                   ('dependencies', FrozenMapping[str, Expression])])
-VolatileProperty.__doc__ = """Hashable representation of a volatile program property. It does not contain the concrete
-value. Using the dependencies attribute to calculate the value might yield unexpected results."""
-
-
-class VolatileValue:
-    """Not hashable"""
-
-    def __init__(self, expression: ExpressionScalar, scope: Scope):
-        self._expression = expression
-        self._scope = scope
-
-    @property
-    def volatile_property(self) -> VolatileProperty:
-        dependencies = self._scope.get_volatile_parameters()
-        dependencies = FrozenDict({parameter_name: dependencies[parameter_name]
-                                   for parameter_name in self._expression.variables
-                                   if parameter_name in dependencies})
-        return VolatileProperty(expression=self._expression, dependencies=dependencies)
-
-    @classmethod
-    def operation(cls, expression, **operands):
-        expression = Expression(expression)
-        assert set(expression.variables) == operands.keys()
-
-        scope = JointScope(FrozenDict(
-            {operand_name: MappedScope(operand._scope, FrozenDict({operand_name: operand._expression}))
-             for operand_name, operand in operands.items()}
-        ))
-        return cls(expression, scope)
-    
-    def __sub__(self, other: int):
-        return type(self)(self._expression - other, self._scope)
-
-    def __mul__(self, other: int):
-        return type(self)(self._expression * other, self._scope)
-
-
-class VolatileRepetitionCount(VolatileValue):
-    def __int__(self):
-        value = self._expression.evaluate_in_scope(self._scope)
-        if not is_integer(value):
-            warnings.warn("Repetition count is no integer. Rounding might lead to unexpected results.")
-        value = int(round(value))
-        if value < 0:
-            warnings.warn("Repetition count is negative. Clamping lead to unexpected results.")
-            value = 0
-        return value
-
-    def update_volatile_dependencies(self, new_constants: Mapping[str, numbers.Number]) -> int:
-        self._scope = self._scope.change_constants(new_constants)
-        return int(self)
-
-    def __eq__(self, other):
-        if type(self) == type(other):
-            return self._scope is other._scope and self._expression == other._expression
-        else:
-            return NotImplemented
+del qupulse
diff --git a/qupulse/_program/waveforms.py b/qupulse/_program/waveforms.py
index a173f3bf3..5038c3988 100644
--- a/qupulse/_program/waveforms.py
+++ b/qupulse/_program/waveforms.py
@@ -1,1233 +1,9 @@
-"""This module contains all waveform classes
+"""Backwards compatibility link to qupulse.program.waveforms"""
 
-Classes:
-    - Waveform: An instantiated pulse which can be sampled to a raw voltage value array.
-"""
+from qupulse.program.waveforms import *
 
-import itertools
-import operator
-import warnings
-from abc import ABCMeta, abstractmethod
-from numbers import Real
-from typing import (
-    AbstractSet, Any, FrozenSet, Iterable, Mapping, NamedTuple, Sequence, Set,
-    Tuple, Union, cast, Optional, List, Hashable)
-from weakref import WeakValueDictionary, ref
+import qupulse.program.waveforms
 
-import numpy as np
+__all__ = qupulse.program.waveforms.__all__
 
-from qupulse import ChannelID
-from qupulse._program.transformation import Transformation
-from qupulse.utils import checked_int_cast, isclose
-from qupulse.utils.types import TimeType, time_from_float
-from qupulse.utils.performance import is_monotonic
-from qupulse.comparable import Comparable
-from qupulse.expressions import ExpressionScalar
-from qupulse.pulses.interpolation import InterpolationStrategy
-from qupulse.utils import checked_int_cast, isclose
-from qupulse.utils.types import TimeType, time_from_float, FrozenDict
-from qupulse._program.transformation import Transformation
-from qupulse.utils import pairwise
-
-class ConstantFunctionPulseTemplateWarning(UserWarning):
-    """  This warning indicates a constant waveform is constructed from a FunctionPulseTemplate """
-    pass
-
-__all__ = ["Waveform", "TableWaveform", "TableWaveformEntry", "FunctionWaveform", "SequenceWaveform",
-           "MultiChannelWaveform", "RepetitionWaveform", "TransformingWaveform", "ArithmeticWaveform",
-           "ConstantFunctionPulseTemplateWarning"]
-
-PULSE_TO_WAVEFORM_ERROR = None  # error margin in pulse template to waveform conversion
-
-#  these are private because there probably will be changes here
-_ALLOCATION_FUNCTION = np.full_like  # pre_allocated = ALLOCATION_FUNCTION(sample_times, **ALLOCATION_FUNCTION_KWARGS)
-_ALLOCATION_FUNCTION_KWARGS = dict(fill_value=np.nan, dtype=float)
-
-
-def _to_time_type(duration: Real) -> TimeType:
-    if isinstance(duration, TimeType):
-        return duration
-    else:
-        return time_from_float(float(duration), absolute_error=PULSE_TO_WAVEFORM_ERROR)
-
-
-class Waveform(Comparable, metaclass=ABCMeta):
-    """Represents an instantiated PulseTemplate which can be sampled to retrieve arrays of voltage
-    values for the hardware."""
-
-    __sampled_cache = WeakValueDictionary()
-
-    __slots__ = ('_duration',)
-
-    def __init__(self, duration: TimeType):
-        self._duration = duration
-
-    @property
-    def duration(self) -> TimeType:
-        """The duration of the waveform in time units."""
-        return self._duration
-
-    @abstractmethod
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        """Sample the waveform at given sample times.
-
-        The unsafe means that there are no sanity checks performed. The provided sample times are assumed to be
-        monotonously increasing and lie in the range of [0, waveform.duration]
-
-        Args:
-            sample_times: Times at which this Waveform will be sampled.
-            output_array: Has to be either None or an array of the same size and type as sample_times. If
-                not None, the sampled values will be written here and this array will be returned
-        Result:
-            The sampled values of this Waveform at the provided sample times. Has the same number of
-            elements as sample_times.
-        """
-
-    def get_sampled(self,
-                    channel: ChannelID,
-                    sample_times: np.ndarray,
-                    output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        """A wrapper to the unsafe_sample method which caches the result. This method enforces the constrains
-        unsafe_sample expects and caches the result to save memory.
-
-        Args:
-            sample_times: Times at which this Waveform will be sampled.
-            output_array: Has to be either None or an array of the same size and type as sample_times. If an array is
-                given, the sampled values will be written into the given array and it will be returned. Otherwise, a new
-                array will be created and cached to save memory.
-
-        Result:
-            The sampled values of this Waveform at the provided sample times. Is `output_array` if provided
-        """
-        if len(sample_times) == 0:
-            if output_array is None:
-                return np.zeros_like(sample_times, dtype=float)
-            elif len(output_array) == len(sample_times):
-                return output_array
-            else:
-                raise ValueError('Output array length and sample time length are different')
-
-        if not is_monotonic(sample_times):
-            raise ValueError('The sample times are not monotonously increasing')
-        if sample_times[0] < 0 or sample_times[-1] > float(self.duration):
-            raise ValueError(f'The sample times [{sample_times[0]}, ..., {sample_times[-1]}] are not in the range'
-                             f' [0, duration={float(self.duration)}]')
-        if channel not in self.defined_channels:
-            raise KeyError('Channel not defined in this waveform: {}'.format(channel))
-
-        constant_value = self.constant_value(channel)
-        if constant_value is None:
-            if output_array is None:
-                # cache the result to save memory
-                result = self.unsafe_sample(channel, sample_times)
-                result.flags.writeable = False
-                key = hash(bytes(result))
-                if key not in self.__sampled_cache:
-                    self.__sampled_cache[key] = result
-                return self.__sampled_cache[key]
-            else:
-                if len(output_array) != len(sample_times):
-                    raise ValueError('Output array length and sample time length are different')
-                # use the user provided memory
-                return self.unsafe_sample(channel=channel,
-                                          sample_times=sample_times,
-                                          output_array=output_array)
-        else:
-            if output_array is None:
-                output_array = np.full_like(sample_times, fill_value=constant_value, dtype=float)
-            else:
-                output_array[:] = constant_value
-            return output_array
-
-    @property
-    @abstractmethod
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        """The channels this waveform should played on. Use
-            :func:`~qupulse.pulses.instructions.get_measurement_windows` to get a waveform for a subset of these."""
-
-    @abstractmethod
-    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
-        """Unsafe version of :func:`~qupulse.pulses.instructions.get_measurement_windows`."""
-
-    def get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
-        """Get a waveform that only describes the channels contained in `channels`.
-
-        Args:
-            channels: A channel set the return value should confine to.
-
-        Raises:
-            KeyError: If `channels` is not a subset of the waveform's defined channels.
-
-        Returns:
-            A waveform with waveform.defined_channels == `channels`
-        """
-        if not channels <= self.defined_channels:
-            raise KeyError('Channels not defined on waveform: {}'.format(channels))
-        if channels == self.defined_channels:
-            return self
-        return self.unsafe_get_subset_for_channels(channels=channels)
-
-    def is_constant(self) -> bool:
-        """Convenience function to check if all channels are constant. The result is equal to
-        `all(waveform.constant_value(ch) is not None for ch in waveform.defined_channels)` but might be more performant.
-
-        Returns:
-            True if all channels have constant values.
-        """
-        return self.constant_value_dict() is not None
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        result = {ch: self.constant_value(ch) for ch in self.defined_channels}
-        if None in result.values():
-            return None
-        else:
-            return result
-
-    def constant_value(self, channel: ChannelID) -> Optional[float]:
-        """Checks if the requested channel has a constant value and returns it if so.
-
-        Guarantee that this assertion passes for every t in waveform duration:
-        >>> assert waveform.constant_value(channel) is None or waveform.constant_value(t) = waveform.get_sampled(channel, t)
-
-        Args:
-            channel: The channel to check
-
-        Returns:
-            None if there is no guarantee that the channel is constant. The value otherwise.
-        """
-        return None
-
-    def __neg__(self):
-        return FunctorWaveform.from_functor(self, {ch: np.negative for ch in self.defined_channels})
-
-    def __pos__(self):
-        return self
-
-    def _sort_key_for_channels(self) -> Sequence[Tuple[str, int]]:
-        """Makes reproducible sorting by defined channels possible"""
-        return sorted((ch, 0) if isinstance(ch, str) else ('', ch) for ch in self.defined_channels)
-
-    def reversed(self) -> 'Waveform':
-        """Returns a reversed version of this waveform."""
-        # We don't check for constness here because const waveforms are supposed to override this method
-        return ReversedWaveform(self)
-
-
-class TableWaveformEntry(NamedTuple('TableWaveformEntry', [('t', Real),
-                                                           ('v', float),
-                                                           ('interp', InterpolationStrategy)])):
-    def __init__(self, t: float, v: float, interp: InterpolationStrategy):
-        if not callable(interp):
-            raise TypeError('{} is neither callable nor of type InterpolationStrategy'.format(interp))
-
-    def __repr__(self):
-        return f'{type(self).__name__}(t={self.t!r}, v={self.v!r}, interp={self.interp!r})'
-
-
-class TableWaveform(Waveform):
-    EntryInInit = Union[TableWaveformEntry, Tuple[float, float, InterpolationStrategy]]
-
-    """Waveform obtained from instantiating a TablePulseTemplate."""
-
-    __slots__ = ('_table', '_channel_id')
-
-    def __init__(self,
-                 channel: ChannelID,
-                 waveform_table: Tuple[TableWaveformEntry, ...]) -> None:
-        """Create a new TableWaveform instance.
-
-        Args:
-            waveform_table: A tuple of instantiated and validated table entries
-        """
-        if not isinstance(waveform_table, tuple):
-            warnings.warn("Please use a tuple of TableWaveformEntry to construct TableWaveform directly",
-                          category=DeprecationWarning)
-            waveform_table = self._validate_input(waveform_table)
-
-        super().__init__(duration=_to_time_type(waveform_table[-1].t))
-
-        self._table = waveform_table
-        self._channel_id = channel
-
-    @staticmethod
-    def _validate_input(input_waveform_table: Sequence[EntryInInit]) -> Union[Tuple[Real, Real],
-                                                                              List[TableWaveformEntry]]:
-        """ Checks that:
-         - the time is increasing,
-         - there are at least two entries
-
-        Optimizations:
-          - removes subsequent entries with same time or voltage values.
-          - checks if the complete waveform is constant. Returns a (duration, value) tuple if this is the case
-
-        Raises:
-            ValueError:
-              - there are less than two entries
-              - the entries are not ordered in time
-              - Any time is negative
-              - The total length is zero
-
-        Returns:
-            A list of de-duplicated table entries
-            OR
-            A (duration, value) tuple if the waveform is constant
-        """
-        # we use an iterator here to avoid duplicate work and be maximally efficient for short tables
-        # We never use StopIteration to abort iteration. It always signifies an error.
-        input_iter = iter(input_waveform_table)
-        try:
-            first_t, first_v, first_interp = next(input_iter)
-        except StopIteration:
-            raise ValueError("Waveform table mut not be empty")
-
-        if first_t != 0.0:
-            raise ValueError('First time entry is not zero.')
-
-        previous_t = 0.0
-        previous_v = first_v
-        output_waveform_table = [TableWaveformEntry(0.0, first_v, first_interp)]
-
-        try:
-            t, v, interp = next(input_iter)
-        except StopIteration:
-            raise ValueError("Waveform table has less than two entries.")
-        if t < 0:
-            raise ValueError('Negative time values are not allowed.')
-
-        # constant_v is None <=> the waveform is constant until up to the current entry
-        constant_v = interp.constant_value((previous_t, previous_v), (t, v))
-
-        for next_t, next_v, next_interp in input_iter:
-            if next_t < t:
-                if next_t < 0:
-                    raise ValueError('Negative time values are not allowed.')
-                else:
-                    raise ValueError('Times are not increasing.')
-
-            if constant_v is not None and interp.constant_value((t, v), (next_t, next_v)) != constant_v:
-                constant_v = None
-
-            if (previous_t != t or t != next_t) and (previous_v != v or v != next_v):
-                # the time and the value differ both either from the next or the previous
-                # otherwise we skip the entry
-                previous_t = t
-                previous_v = v
-                output_waveform_table.append(TableWaveformEntry(t, v, interp))
-
-            t, v, interp = next_t, next_v, next_interp
-
-        # Until now, we only checked that the time does not decrease. We require an increase because duration == 0
-        # waveforms are ill-formed. t is now the time of the last entry.
-        if t == 0:
-            raise ValueError('Last time entry is zero.')
-
-        if constant_v is not None:
-            # the waveform is constant
-            return t, constant_v
-        else:
-            # we must still add the last entry to the table
-            output_waveform_table.append(TableWaveformEntry(t, v, interp))
-            return output_waveform_table
-
-    def is_constant(self) -> bool:
-        # only correct if `from_table` is used
-        return False
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        # only correct if `from_table` is used
-        return None
-
-    @classmethod
-    def from_table(cls, channel: ChannelID, table: Sequence[EntryInInit]) -> Union['TableWaveform', 'ConstantWaveform']:
-        table = cls._validate_input(table)
-        if isinstance(table, tuple):
-            duration, amplitude = table
-            return ConstantWaveform(duration=duration, amplitude=amplitude, channel=channel)
-        else:
-            return TableWaveform(channel, tuple(table))
-
-    @property
-    def compare_key(self) -> Any:
-        return self._channel_id, self._table
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        if output_array is None:
-            output_array = _ALLOCATION_FUNCTION(sample_times, **_ALLOCATION_FUNCTION_KWARGS)
-
-        if PULSE_TO_WAVEFORM_ERROR:
-            # we need to replace the last entry's t with self.duration
-            *entries, last = self._table
-            entries.append(TableWaveformEntry(float(self.duration), last.v, last.interp))
-        else:
-            entries = self._table
-
-        for entry1, entry2 in pairwise(entries):
-            indices = slice(np.searchsorted(sample_times, entry1.t, 'left'),
-                            np.searchsorted(sample_times, entry2.t, 'right'))
-            output_array[indices] = \
-                entry2.interp((float(entry1.t), entry1.v),
-                              (float(entry2.t), entry2.v),
-                              sample_times[indices])
-        return output_array
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        return {self._channel_id}
-
-    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
-        return self
-
-    def __repr__(self):
-        return f'{type(self).__name__}(channel={self._channel_id!r}, waveform_table={self._table!r})'
-
-
-class ConstantWaveform(Waveform):
-
-    # TODO: remove
-    _is_constant_waveform = True
-
-    __slots__ = ('_amplitude', '_channel')
-
-    def __init__(self, duration: Real, amplitude: Any, channel: ChannelID):
-        """ Create a qupulse waveform corresponding to a ConstantPulseTemplate """
-        super().__init__(duration=_to_time_type(duration))
-        self._amplitude = amplitude
-        self._channel = channel
-
-    @classmethod
-    def from_mapping(cls, duration: Real, constant_values: Mapping[ChannelID, float]) -> Union['ConstantWaveform',
-                                                                                               'MultiChannelWaveform']:
-        """Construct a ConstantWaveform or a MultiChannelWaveform of ConstantWaveforms with given duration and values"""
-        assert constant_values
-        duration = _to_time_type(duration)
-        if len(constant_values) == 1:
-            (channel, amplitude), = constant_values.items()
-            return cls(duration, amplitude=amplitude, channel=channel)
-        else:
-            return MultiChannelWaveform([cls(duration, amplitude=amplitude, channel=channel)
-                                         for channel, amplitude in constant_values.items()])
-
-    def is_constant(self) -> bool:
-        return True
-
-    def constant_value(self, channel: ChannelID) -> Optional[float]:
-        assert channel == self._channel
-        return self._amplitude
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        return {self._channel: self._amplitude}
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        """The channels this waveform should played on. Use
-            :func:`~qupulse.pulses.instructions.get_measurement_windows` to get a waveform for a subset of these."""
-
-        return {self._channel}
-
-    @property
-    def compare_key(self) -> Tuple[Any, ...]:
-        return self._duration, self._amplitude, self._channel
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        if output_array is None:
-            return np.full_like(sample_times, fill_value=self._amplitude, dtype=float)
-        else:
-            output_array[:] = self._amplitude
-            return output_array
-
-    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> Waveform:
-        """Unsafe version of :func:`~qupulse.pulses.instructions.get_measurement_windows`."""
-        return self
-
-    def __repr__(self):
-        return f"{type(self).__name__}(duration={self.duration!r}, "\
-               f"amplitude={self._amplitude!r}, channel={self._channel!r})"
-
-    def reversed(self) -> 'Waveform':
-        return self
-
-
-class FunctionWaveform(Waveform):
-    """Waveform obtained from instantiating a FunctionPulseTemplate."""
-
-    __slots__ = ('_expression', '_channel_id')
-
-    def __init__(self, expression: ExpressionScalar,
-                 duration: float,
-                 channel: ChannelID) -> None:
-        """Creates a new FunctionWaveform instance.
-
-        Args:
-            expression: The function represented by this FunctionWaveform
-                as a mathematical expression where 't' denotes the time variable. It must not have other variables
-            duration: The duration of the waveform
-            measurement_windows: A list of measurement windows
-            channel: The channel this waveform is played on
-        """
-
-        if set(expression.variables) - set('t'):
-            raise ValueError('FunctionWaveforms may not depend on anything but "t"')
-        elif not expression.variables:
-            warnings.warn("Constant FunctionWaveform is not recommended as the constant propagation will be suboptimal",
-                          category=ConstantFunctionPulseTemplateWarning)
-        super().__init__(duration=_to_time_type(duration))
-        self._expression = expression
-        self._channel_id = channel
-
-    @classmethod
-    def from_expression(cls, expression: ExpressionScalar, duration: float, channel: ChannelID) -> Union['FunctionWaveform', ConstantWaveform]:
-        if expression.variables:
-            return cls(expression, duration, channel)
-        else:
-            return ConstantWaveform(amplitude=expression.evaluate_numeric(), duration=duration, channel=channel)
-
-    def is_constant(self) -> bool:
-        # only correct if `from_expression` is used
-        return False
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        # only correct if `from_expression` is used
-        return None
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        return {self._channel_id}
-
-    @property
-    def compare_key(self) -> Any:
-        return self._channel_id, self._expression, self._duration
-
-    @property
-    def duration(self) -> TimeType:
-        return self._duration
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        evaluated = self._expression.evaluate_numeric(t=sample_times)
-        if output_array is None:
-            if self._expression.variables:
-                return evaluated.astype(float)
-            else:
-                return np.full_like(sample_times, fill_value=float(evaluated), dtype=float)
-        else:
-            output_array[:] = evaluated
-            return output_array
-
-    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> Waveform:
-        return self
-
-    def __repr__(self):
-        return f"{type(self).__name__}(duration={self.duration!r}, "\
-               f"expression={self._expression!r}, channel={self._channel_id!r})"
-
-
-class SequenceWaveform(Waveform):
-    """This class allows putting multiple PulseTemplate together in one waveform on the hardware."""
-
-    __slots__ = ('_sequenced_waveforms', )
-
-    def __init__(self, sub_waveforms: Iterable[Waveform]):
-        """Use Waveform.from_sequence for optimal construction
-
-        :param subwaveforms: All waveforms must have the same defined channels
-        """
-        if not sub_waveforms:
-            raise ValueError(
-                "SequenceWaveform cannot be constructed without channel waveforms."
-            )
-
-        # do not fail on iterators although we do not allow them as an argument
-        sequenced_waveforms = tuple(sub_waveforms)
-
-        super().__init__(duration=sum(waveform.duration for waveform in sequenced_waveforms))
-        self._sequenced_waveforms = sequenced_waveforms
-
-        defined_channels = self._sequenced_waveforms[0].defined_channels
-        if not all(waveform.defined_channels == defined_channels
-                   for waveform in itertools.islice(self._sequenced_waveforms, 1, None)):
-            for waveform in self._sequenced_waveforms[1:]:
-                 if not waveform.defined_channels == self.defined_channels:
-                     print(f"SequenceWaveform: defined channels {self.defined_channels} do not match {waveform.defined_channels} ")
-            raise ValueError(
-                "SequenceWaveform cannot be constructed from waveforms of different"
-                "defined channels."
-            )
-
-    @classmethod
-    def from_sequence(cls, waveforms: Sequence['Waveform']) -> 'Waveform':
-        """Returns a waveform the represents the given sequence of waveforms. Applies some optimizations."""
-        assert waveforms, "Sequence must not be empty"
-        if len(waveforms) == 1:
-            return waveforms[0]
-
-        flattened = []
-        constant_values = waveforms[0].constant_value_dict()
-        for wf in waveforms:
-            if constant_values and constant_values != wf.constant_value_dict():
-                constant_values = None
-            if isinstance(wf, cls):
-                flattened.extend(wf.sequenced_waveforms)
-            else:
-                flattened.append(wf)
-        if constant_values is None:
-            return cls(sub_waveforms=flattened)
-        else:
-            duration = sum(wf.duration for wf in flattened)
-            return ConstantWaveform.from_mapping(duration, constant_values)
-
-    def is_constant(self) -> bool:
-        # only correct if from_sequence is used for construction
-        return False
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        # only correct if from_sequence is used for construction
-        return None
-
-    def constant_value(self, channel: ChannelID) -> Optional[float]:
-        v = None
-        for wf in self._sequenced_waveforms:
-            wf_cv = wf.constant_value(channel)
-            if wf_cv is None:
-                return None
-            elif wf_cv == v:
-                continue
-            elif v is None:
-                v = wf_cv
-            else:
-                assert v != wf_cv
-                return None
-        return v
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        return self._sequenced_waveforms[0].defined_channels
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        if output_array is None:
-            output_array = _ALLOCATION_FUNCTION(sample_times, **_ALLOCATION_FUNCTION_KWARGS)
-        time = 0
-        for subwaveform in self._sequenced_waveforms:
-            # before you change anything here, make sure to understand the difference between basic and advanced
-            # indexing in numpy and their copy/reference behaviour
-            end = time + subwaveform.duration
-
-            indices = slice(*np.searchsorted(sample_times, (float(time), float(end)), 'left'))
-            subwaveform.unsafe_sample(channel=channel,
-                                      sample_times=sample_times[indices]-np.float64(time),
-                                      output_array=output_array[indices])
-            time = end
-        return output_array
-
-    @property
-    def compare_key(self) -> Tuple[Waveform]:
-        return self._sequenced_waveforms
-
-    @property
-    def duration(self) -> TimeType:
-        return self._duration
-
-    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
-        return SequenceWaveform.from_sequence([
-            sub_waveform.unsafe_get_subset_for_channels(channels & sub_waveform.defined_channels)
-            for sub_waveform in self._sequenced_waveforms if sub_waveform.defined_channels & channels])
-
-    @property
-    def sequenced_waveforms(self) -> Sequence[Waveform]:
-        return self._sequenced_waveforms
-
-    def __repr__(self):
-        return f"{type(self).__name__}({self._sequenced_waveforms})"
-
-
-class MultiChannelWaveform(Waveform):
-    """A MultiChannelWaveform is a Waveform object that allows combining arbitrary Waveform objects
-    to into a single waveform defined for several channels.
-
-    The number of channels used by the MultiChannelWaveform object is the sum of the channels used
-    by the Waveform objects it consists of.
-
-    MultiChannelWaveform allows an arbitrary mapping of channels defined by the Waveforms it
-    consists of and the channels it defines. For example, if the MultiChannelWaveform consists
-    of a two Waveform objects A and B which define two channels each, then the channels of the
-    MultiChannelWaveform may be 0: A.1, 1: B.0, 2: B.1, 3: A.0 where A.0 means channel 0 of Waveform
-    object A.
-
-    The following constraints must hold:
-     - The durations of all Waveform objects must be equal.
-     - The channel mapping must be sane, i.e., no channel of the MultiChannelWaveform must be
-        assigned more than one channel of any Waveform object it consists of
-    """
-
-    __slots__ = ('_sub_waveforms', '_defined_channels')
-
-    def __init__(self, sub_waveforms: List[Waveform]) -> None:
-        """Create a new MultiChannelWaveform instance.
-        Use `MultiChannelWaveform.from_parallel` for optimal construction.
-
-        Requires a list of subwaveforms in the form (Waveform, List(int)) where the list defines
-        the channel mapping, i.e., a value y at index x in the list means that channel x of the
-        subwaveform will be mapped to channel y of this MultiChannelWaveform object.
-
-        Args:
-            sub_waveforms: The list of sub waveforms of this
-                MultiChannelWaveform. List might get sorted!
-        Raises:
-            ValueError, if a channel mapping is out of bounds of the channels defined by this
-                MultiChannelWaveform
-            ValueError, if several subwaveform channels are assigned to a single channel of this
-                MultiChannelWaveform
-            ValueError, if subwaveforms have inconsistent durations
-        """
-
-        if not sub_waveforms:
-            raise ValueError(
-                "MultiChannelWaveform cannot be constructed without channel waveforms."
-            )
-
-        # sort the waveforms with their defined channels to make compare key reproducible
-        if not isinstance(sub_waveforms, list):
-            sub_waveforms = list(sub_waveforms)
-        sub_waveforms.sort(key=lambda wf: wf._sort_key_for_channels())
-
-        super().__init__(duration=sub_waveforms[0].duration)
-        self._sub_waveforms = tuple(sub_waveforms)
-
-        defined_channels = set()
-        for waveform in self._sub_waveforms:
-            if waveform.defined_channels & defined_channels:
-                raise ValueError('Channel may not be defined in multiple waveforms',
-                                 waveform.defined_channels & defined_channels)
-            defined_channels |= waveform.defined_channels
-        self._defined_channels = frozenset(defined_channels)
-
-        if not all(isclose(waveform.duration, self.duration) for waveform in self._sub_waveforms[1:]):
-            # meaningful error message:
-            durations = {}
-
-            for waveform in self._sub_waveforms:
-                for duration, channels in durations.items():
-                    if isclose(waveform.duration, duration):
-                        channels.update(waveform.defined_channels)
-                        break
-                else:
-                    durations[waveform.duration] = set(waveform.defined_channels)
-
-            raise ValueError(
-                "MultiChannelWaveform cannot be constructed from channel waveforms of different durations.",
-                durations
-            )
-
-    @staticmethod
-    def from_parallel(waveforms: Sequence[Waveform]) -> Waveform:
-        assert waveforms, "ARgument must not be empty"
-        if len(waveforms) == 1:
-            return waveforms[0]
-
-        # we do not look at constant values here because there is no benefit. We would need to construct a new
-        # MultiChannelWaveform anyways
-
-        # avoid unnecessary multi channel nesting
-        flattened = []
-        for waveform in waveforms:
-            if isinstance(waveform, MultiChannelWaveform):
-                flattened.extend(waveform._sub_waveforms)
-            else:
-                flattened.append(waveform)
-
-        return MultiChannelWaveform(flattened)
-
-    def is_constant(self) -> bool:
-        return all(wf.is_constant() for wf in self._sub_waveforms)
-
-    def constant_value(self, channel: ChannelID) -> Optional[float]:
-        return self[channel].constant_value(channel)
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        d = {}
-        for wf in self._sub_waveforms:
-            wf_d = wf.constant_value_dict()
-            if wf_d is None:
-                return None
-            else:
-                d.update(wf_d)
-        return d
-
-    @property
-    def duration(self) -> TimeType:
-        return self._sub_waveforms[0].duration
-
-    def __getitem__(self, key: ChannelID) -> Waveform:
-        for waveform in self._sub_waveforms:
-            if key in waveform.defined_channels:
-                return waveform
-        raise KeyError('Unknown channel ID: {}'.format(key), key)
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        return self._defined_channels
-
-    @property
-    def compare_key(self) -> Any:
-        # sort with channels
-        return self._sub_waveforms
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        return self[channel].unsafe_sample(channel, sample_times, output_array)
-
-    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
-        relevant_sub_waveforms = [swf for swf in self._sub_waveforms if swf.defined_channels & channels]
-        if len(relevant_sub_waveforms) == 1:
-            return relevant_sub_waveforms[0].get_subset_for_channels(channels)
-        elif len(relevant_sub_waveforms) > 1:
-            return MultiChannelWaveform.from_parallel(
-                [sub_waveform.get_subset_for_channels(channels & sub_waveform.defined_channels)
-                 for sub_waveform in relevant_sub_waveforms])
-        else:
-            raise KeyError('Unknown channels: {}'.format(channels))
-
-    def __repr__(self):
-        return f"{type(self).__name__}({self._sub_waveforms!r})"
-
-
-class RepetitionWaveform(Waveform):
-    """This class allows putting multiple PulseTemplate together in one waveform on the hardware."""
-
-    __slots__ = ('_body', '_repetition_count')
-
-    def __init__(self, body: Waveform, repetition_count: int):
-        repetition_count = checked_int_cast(repetition_count)
-        if repetition_count < 1 or not isinstance(repetition_count, int):
-            raise ValueError('Repetition count must be an integer >0')
-
-        super().__init__(duration=body.duration * repetition_count)
-        self._body = body
-        self._repetition_count = repetition_count
-
-    @classmethod
-    def from_repetition_count(cls, body: Waveform, repetition_count: int) -> Waveform:
-        constant_values = body.constant_value_dict()
-        if constant_values is None:
-            return RepetitionWaveform(body, repetition_count)
-        else:
-            return ConstantWaveform.from_mapping(body.duration * repetition_count, constant_values)
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        return self._body.defined_channels
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        if output_array is None:
-            output_array = _ALLOCATION_FUNCTION(sample_times, **_ALLOCATION_FUNCTION_KWARGS)
-        body_duration = self._body.duration
-        time = 0
-        for _ in range(self._repetition_count):
-            end = time + body_duration
-            indices = slice(*np.searchsorted(sample_times, (float(time), float(end)), 'left'))
-            self._body.unsafe_sample(channel=channel,
-                                     sample_times=sample_times[indices] - float(time),
-                                     output_array=output_array[indices])
-            time = end
-        return output_array
-
-    @property
-    def compare_key(self) -> Tuple[Any, int]:
-        return self._body.compare_key, self._repetition_count
-
-    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> Waveform:
-        return RepetitionWaveform.from_repetition_count(
-            body=self._body.unsafe_get_subset_for_channels(channels),
-            repetition_count=self._repetition_count)
-
-    def is_constant(self) -> bool:
-        return self._body.is_constant()
-
-    def constant_value(self, channel: ChannelID) -> Optional[float]:
-        return self._body.constant_value(channel)
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        return self._body.constant_value_dict()
-
-    def __repr__(self):
-        return f"{type(self).__name__}(body={self._body!r}, repetition_count={self._repetition_count!r})"
-
-
-class TransformingWaveform(Waveform):
-    __slots__ = ('_inner_waveform', '_transformation', '_cached_data', '_cached_times')
-
-    def __init__(self, inner_waveform: Waveform, transformation: Transformation):
-        """"""
-        super(TransformingWaveform, self).__init__(duration=inner_waveform.duration)
-        self._inner_waveform = inner_waveform
-        self._transformation = transformation
-
-        # cache data of inner channels based identified and invalidated by the sample times
-        self._cached_data = None
-        self._cached_times = lambda: None
-
-    @classmethod
-    def from_transformation(cls, inner_waveform: Waveform, transformation: Transformation) -> Waveform:
-        constant_values = inner_waveform.constant_value_dict()
-
-        if constant_values is None or not transformation.is_constant_invariant():
-            return cls(inner_waveform, transformation)
-
-        transformed_constant_values = {key: float(value) for key, value in transformation(0., constant_values).items()}
-        return ConstantWaveform.from_mapping(inner_waveform.duration, transformed_constant_values)
-
-    def is_constant(self) -> bool:
-        # only true if `from_transformation` was used
-        return False
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        # only true if `from_transformation` was used
-        return None
-
-    def constant_value(self, channel: ChannelID) -> Optional[float]:
-        if not self._transformation.is_constant_invariant():
-            return None
-        in_channels = self._transformation.get_input_channels({channel})
-        in_values = {ch: self._inner_waveform.constant_value(ch) for ch in in_channels}
-        if any(val is None for val in in_values.values()):
-            return None
-        else:
-            return self._transformation(0., in_values)[channel]
-
-    @property
-    def inner_waveform(self) -> Waveform:
-        return self._inner_waveform
-
-    @property
-    def transformation(self) -> Transformation:
-        return self._transformation
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        return self.transformation.get_output_channels(self.inner_waveform.defined_channels)
-
-    @property
-    def compare_key(self) -> Tuple[Waveform, Transformation]:
-        return self.inner_waveform, self.transformation
-
-    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> 'SubsetWaveform':
-        return SubsetWaveform(self, channel_subset=channels)
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        if self._cached_times() is not sample_times:
-            self._cached_data = dict()
-            self._cached_times = ref(sample_times)
-
-        if channel not in self._cached_data:
-
-            inner_channels = self.transformation.get_input_channels({channel})
-
-            inner_data = {inner_channel: self.inner_waveform.unsafe_sample(inner_channel, sample_times)
-                          for inner_channel in inner_channels}
-
-            outer_data = self.transformation(sample_times, inner_data)
-            self._cached_data.update(outer_data)
-
-        if output_array is None:
-            output_array = self._cached_data[channel]
-        else:
-            output_array[:] = self._cached_data[channel]
-
-        return output_array
-
-
-class SubsetWaveform(Waveform):
-    __slots__ = ('_inner_waveform', '_channel_subset')
-
-    def __init__(self, inner_waveform: Waveform, channel_subset: Set[ChannelID]):
-        super().__init__(duration=inner_waveform.duration)
-        self._inner_waveform = inner_waveform
-        self._channel_subset = frozenset(channel_subset)
-
-    @property
-    def inner_waveform(self) -> Waveform:
-        return self._inner_waveform
-
-    @property
-    def defined_channels(self) -> FrozenSet[ChannelID]:
-        return self._channel_subset
-
-    @property
-    def compare_key(self) -> Tuple[frozenset, Waveform]:
-        return self.defined_channels, self.inner_waveform
-
-    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> Waveform:
-        return self.inner_waveform.get_subset_for_channels(channels)
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        return self.inner_waveform.unsafe_sample(channel, sample_times, output_array)
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        d = self._inner_waveform.constant_value_dict()
-        if d is not None:
-            return {ch: d[ch] for ch in self._channel_subset}
-
-    def constant_value(self, channel: ChannelID) -> Optional[float]:
-        if channel not in self._channel_subset:
-            raise KeyError(channel)
-        return self._inner_waveform.constant_value(channel)
-
-
-class ArithmeticWaveform(Waveform):
-    """Channels only present in one waveform have the operations neutral element on the other."""
-
-    numpy_operator_map = {'+': np.add,
-                          '-': np.subtract}
-    operator_map = {'+': operator.add,
-                    '-': operator.sub}
-
-    rhs_only_map = {'+': operator.pos,
-                    '-': operator.neg}
-    numpy_rhs_only_map = {'+': np.positive,
-                          '-': np.negative}
-
-    __slots__ = ('_lhs', '_rhs', '_arithmetic_operator')
-
-    def __init__(self,
-                 lhs: Waveform,
-                 arithmetic_operator: str,
-                 rhs: Waveform):
-        super().__init__(duration=lhs.duration)
-        self._lhs = lhs
-        self._rhs = rhs
-        self._arithmetic_operator = arithmetic_operator
-
-        assert np.isclose(float(self._lhs.duration), float(self._rhs.duration))
-        assert arithmetic_operator in self.operator_map
-
-    @classmethod
-    def from_operator(cls, lhs: Waveform, arithmetic_operator: str, rhs: Waveform):
-        # one could optimize rhs_cv to being only created if lhs_cv is not None but this makes the code harder to read
-        lhs_cv = lhs.constant_value_dict()
-        rhs_cv = rhs.constant_value_dict()
-        if lhs_cv is None or rhs_cv is None:
-            return cls(lhs, arithmetic_operator, rhs)
-
-        else:
-            constant_values = dict(lhs_cv)
-            op = cls.operator_map[arithmetic_operator]
-            rhs_op = cls.rhs_only_map[arithmetic_operator]
-
-            for ch, rhs_val in rhs_cv.items():
-                if ch in constant_values:
-                    constant_values[ch] = op(constant_values[ch], rhs_val)
-                else:
-                    constant_values[ch] = rhs_op(rhs_val)
-
-            duration = lhs.duration
-            assert isclose(duration, rhs.duration)
-
-            return ConstantWaveform.from_mapping(duration, constant_values)
-
-    def constant_value(self, channel: ChannelID) -> Optional[float]:
-        if channel not in self._rhs.defined_channels:
-            return self._lhs.constant_value(channel)
-        rhs = self._rhs.constant_value(channel)
-        if rhs is None:
-            return None
-
-        if channel in self._lhs.defined_channels:
-            lhs = self._lhs.constant_value(channel)
-            if lhs is None:
-                return None
-
-            return self.operator_map[self._arithmetic_operator](lhs, rhs)
-        else:
-            return self.rhs_only_map[self._arithmetic_operator](rhs)
-
-    def is_constant(self) -> bool:
-        # only correct if from_operator is used
-        return False
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        # only correct if from_operator is used
-        return None
-
-    @property
-    def lhs(self) -> Waveform:
-        return self._lhs
-
-    @property
-    def rhs(self) -> Waveform:
-        return self._rhs
-
-    @property
-    def arithmetic_operator(self) -> str:
-        return self._arithmetic_operator
-
-    @property
-    def duration(self) -> TimeType:
-        return self._lhs.duration
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        return self._lhs.defined_channels | self._rhs.defined_channels
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        if channel in self._lhs.defined_channels:
-            lhs = self._lhs.unsafe_sample(channel=channel, sample_times=sample_times, output_array=output_array)
-        else:
-            lhs = None
-
-        if channel in self._rhs.defined_channels:
-            rhs = self._rhs.unsafe_sample(channel=channel, sample_times=sample_times,
-                                          output_array=None if lhs is not None else output_array)
-        else:
-            rhs = None
-
-        if rhs is not None and lhs is not None:
-            arithmetic_operator = self.numpy_operator_map[self._arithmetic_operator]
-            if output_array is None:
-                output_array = lhs
-            return arithmetic_operator(lhs, rhs, out=output_array)
-
-        else:
-            if lhs is None:
-                assert rhs is not None, "channel %r not in defined channels (internal bug)" % channel
-                return self.numpy_rhs_only_map[self._arithmetic_operator](rhs, out=output_array)
-            else:
-                return lhs
-
-    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> Waveform:
-        # TODO: optimization possible
-        return SubsetWaveform(self, channels)
-
-    @property
-    def compare_key(self) -> Tuple[str, Waveform, Waveform]:
-        return self._arithmetic_operator, self._lhs, self._rhs
-
-
-class FunctorWaveform(Waveform):
-    # TODO: Use Protocol to enforce that it accepts second argument has the keyword out
-    Functor = callable
-
-    __slots__ = ('_inner_waveform', '_functor')
-
-    """Apply a channel wise functor that works inplace to all results. The functor must accept two arguments"""
-    def __init__(self, inner_waveform: Waveform, functor: Mapping[ChannelID, Functor]):
-        super(FunctorWaveform, self).__init__(duration=inner_waveform.duration)
-        self._inner_waveform = inner_waveform
-        self._functor = dict(functor.items())
-
-        assert set(functor.keys()) == inner_waveform.defined_channels, ("There is no default identity mapping (yet)."
-                                                                        "File an issue on github if you need it.")
-
-    @classmethod
-    def from_functor(cls, inner_waveform: Waveform, functor: Mapping[ChannelID, Functor]):
-        constant_values = inner_waveform.constant_value_dict()
-        if constant_values is None:
-            return FunctorWaveform(inner_waveform, functor)
-
-        funced_constant_values = {ch: functor[ch](val) for ch, val in constant_values.items()}
-        return ConstantWaveform.from_mapping(inner_waveform.duration, funced_constant_values)
-
-    def is_constant(self) -> bool:
-        # only correct if `from_functor` was used
-        return False
-
-    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
-        # only correct if `from_functor` was used
-        return None
-
-    def constant_value(self, channel: ChannelID) -> Optional[float]:
-        inner = self._inner_waveform.constant_value(channel)
-        if inner is None:
-            return None
-        else:
-            return self._functor[channel](inner)
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        return self._inner_waveform.defined_channels
-
-    def unsafe_sample(self,
-                      channel: ChannelID,
-                      sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        inner_output = self._inner_waveform.unsafe_sample(channel, sample_times, output_array)
-        return self._functor[channel](inner_output, out=inner_output)
-
-    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> Waveform:
-        return FunctorWaveform.from_functor(
-            self._inner_waveform.unsafe_get_subset_for_channels(channels),
-            {ch: self._functor[ch] for ch in channels})
-
-    @property
-    def compare_key(self) -> Tuple[Waveform, FrozenSet]:
-        return self._inner_waveform, frozenset(self._functor.items())
-
-
-class ReversedWaveform(Waveform):
-    """Reverses the inner waveform in time."""
-
-    __slots__ = ('_inner',)
-
-    def __init__(self, inner: Waveform):
-        super().__init__(duration=inner.duration)
-        self._inner = inner
-
-    @classmethod
-    def from_to_reverse(cls, inner: Waveform) -> Waveform:
-        if inner.constant_value_dict():
-            return inner
-        else:
-            return cls(inner)
-
-    def unsafe_sample(self, channel: ChannelID, sample_times: np.ndarray,
-                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
-        inner_sample_times = (float(self.duration) - sample_times)[::-1]
-        if output_array is None:
-            return self._inner.unsafe_sample(channel, inner_sample_times, None)[::-1]
-        else:
-            inner_output_array = output_array[::-1]
-            inner_output_array = self._inner.unsafe_sample(channel, inner_sample_times, output_array=inner_output_array)
-            if inner_output_array.base not in (output_array, output_array.base):
-                # TODO: is there a guarantee by numpy we never end up here?
-                output_array[:] = inner_output_array[::-1]
-            return output_array
-
-    @property
-    def defined_channels(self) -> AbstractSet[ChannelID]:
-        return self._inner.defined_channels
-
-    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
-        return ReversedWaveform.from_to_reverse(self._inner.unsafe_get_subset_for_channels(channels))
-
-    @property
-    def compare_key(self) -> Hashable:
-        return self._inner.compare_key
-
-    def reversed(self) -> 'Waveform':
-        return self._inner
+del qupulse
diff --git a/qupulse/expressions/__init__.py b/qupulse/expressions/__init__.py
new file mode 100644
index 000000000..52aa5f325
--- /dev/null
+++ b/qupulse/expressions/__init__.py
@@ -0,0 +1,76 @@
+"""This subpackage contains qupulse's expression logic. The submodule :py:mod:`.expressions.protocol` defines the :py:class:`typing.Protocol`
+that expression functionality providers must implement. This allows to substitute the powerful and expressive but slow
+default implementation with a faster less expressive backend.
+
+The default implementation is in :py:mod:`.expressions.sympy`.
+
+There is are wrapper classes for finding non-protocol uses of expression in :py:mod:`.expressions.wrapper`. Define
+``QUPULSE_EXPRESSION_WRAPPER`` environment variable when running python to wrap all expression usages.
+"""
+
+from typing import Type, TypeVar
+from numbers import Real
+import os
+
+import numpy as np
+import sympy as sp
+
+from . import sympy, protocol, wrapper
+
+
+__all__ = ["Expression", "ExpressionVector", "ExpressionScalar",
+           "NonNumericEvaluation", "ExpressionVariableMissingException"]
+
+
+Expression: Type[protocol.Expression] = sympy.Expression
+ExpressionScalar: Type[protocol.ExpressionScalar] = sympy.ExpressionScalar
+ExpressionVector: Type[protocol.ExpressionVector] = sympy.ExpressionVector
+
+
+if os.environ.get('QUPULSE_EXPRESSION_WRAPPER', None):  # pragma: no cover
+    Expression, ExpressionScalar, ExpressionVector = wrapper.make_wrappers(sympy.Expression,
+                                                                           sympy.ExpressionScalar,
+                                                                           sympy.ExpressionVector)
+
+
+ExpressionLike = TypeVar('ExpressionLike', str, Real, sp.Expr, ExpressionScalar)
+
+
+class ExpressionVariableMissingException(Exception):
+    """An exception indicating that a variable value was not provided during expression evaluation.
+
+    See also:
+         qupulse.expressions.Expression
+    """
+
+    def __init__(self, variable: str, expression: Expression) -> None:
+        super().__init__()
+        self.variable = variable
+        self.expression = expression
+
+    def __str__(self) -> str:
+        return f"Could not evaluate <{self.expression}>: A value for variable <{self.variable}> is missing!"
+
+
+class NonNumericEvaluation(Exception):
+    """An exception that is raised if the result of evaluate_numeric is not a number.
+
+    See also:
+        qupulse.expressions.Expression.evaluate_numeric
+    """
+
+    def __init__(self, expression: Expression, non_numeric_result, call_arguments):
+        self.expression = expression
+        self.non_numeric_result = non_numeric_result
+        self.call_arguments = call_arguments
+
+    def __str__(self) -> str:
+        if isinstance(self.non_numeric_result, np.ndarray):
+            dtype = self.non_numeric_result.dtype
+
+            if dtype == np.dtype('O'):
+                dtypes = set(map(type, self.non_numeric_result.flat))
+                return f"The result of evaluate_numeric is an array with the types {dtypes} which is not purely numeric"
+        else:
+            dtype = type(self.non_numeric_result)
+        return f"The result of evaluate_numeric is of type {dtype} which is not a number"
diff --git a/qupulse/expressions/protocol.py b/qupulse/expressions/protocol.py
new file mode 100644
index 000000000..667337c3d
--- /dev/null
+++ b/qupulse/expressions/protocol.py
@@ -0,0 +1,105 @@
+"""This module contains the interface / protocol descriptions of ``Expression``, ``ExpressionScalar`` and
+``ExpressionVector``."""
+
+from typing import Mapping, Union, Sequence, Hashable, Any, Protocol
+from numbers import Real
+
+import numpy as np
+
+
+class Ordered(Protocol):
+    def __lt__(self, other):
+        pass
+
+    def __le__(self, other):
+        pass
+
+    def __gt__(self, other):
+        pass
+
+    def __ge__(self, other):
+        pass
+
+
+class Scalar(Protocol):
+    def __add__(self, other):
+        pass
+
+    def __sub__(self, other):
+        pass
+
+    def __mul__(self, other):
+        pass
+
+    def __truediv__(self, other):
+        pass
+
+    def __floordiv__(self, other):
+        pass
+
+    def __ceil__(self):
+        pass
+
+    def __floor__(self):
+        pass
+
+    def __float__(self):
+        pass
+
+    def __int__(self):
+        pass
+
+    def __abs__(self):
+        pass
+
+
+class Expression(Hashable, Protocol):
+    """This protocol defines how Expressions are allowed to be used in qupulse."""
+
+    def evaluate_in_scope(self, scope: Mapping) -> Union[Real, np.ndarray]:
+        """Evaluate the expression by taking the variables from the given scope (typically of type Scope, but it can be
+        any mapping.)
+        Args:
+            scope:
+
+        Returns:
+
+        """
+
+    def evaluate_symbolic(self, substitutions: Mapping[str, Any]) -> 'Expression':
+        """Substitute a part of the expression for another"""
+
+    def evaluate_time_dependent(self, scope: Mapping) -> Union['Expression', Real, np.ndarray]:
+        """Evaluate to a time dependent expression or a constant."""
+
+    @property
+    def variables(self) -> Sequence[str]:
+        """ Get all free variables in the expression.
+
+        Returns:
+            A collection of all free variables occurring in the expression.
+        """
+        raise NotImplementedError()
+
+    @classmethod
+    def make(cls,
+             expression_or_dict,
+             numpy_evaluation=None) -> 'Expression':
+        """Backward compatible expression generation to allow creation from dict."""
+        raise NotImplementedError()
+
+    @property
+    def underlying_expression(self) -> Any:
+        """Return some internal unspecified representation"""
+        raise NotImplementedError()
+
+    def get_serialization_data(self):
+        raise NotImplementedError()
+
+
+class ExpressionScalar(Expression, Scalar, Ordered, Protocol):
+    pass
+
+
+class ExpressionVector(Expression, Protocol):
+    pass
diff --git a/qupulse/expressions.py b/qupulse/expressions/sympy.py
similarity index 88%
rename from qupulse/expressions.py
rename to qupulse/expressions/sympy.py
index bbfad3eed..150a1b6a5 100644
--- a/qupulse/expressions.py
+++ b/qupulse/expressions/sympy.py
@@ -18,7 +18,9 @@
     get_most_simple_representation, get_variables, evaluate_lamdified_exact_rational
 from qupulse.utils.types import TimeType
 
-__all__ = ["Expression", "ExpressionVariableMissingException", "ExpressionScalar", "ExpressionVector", "ExpressionLike"]
+import qupulse.expressions
+
+__all__ = ["Expression", "ExpressionScalar", "ExpressionVector"]
 
 
 _ExpressionType = TypeVar('_ExpressionType', bound='Expression')
@@ -60,9 +62,9 @@ def _parse_evaluate_numeric_vector(vector_result: numpy.ndarray) -> numpy.ndarra
     if not issubclass(vector_result.dtype.type, allowed_scalar):
         obj_types = set(map(type, vector_result.flat))
         if all(issubclass(obj_type, sympy.Integer) for obj_type in obj_types):
-            result = vector_result.astype(numpy.int64)
+            vector_result = vector_result.astype(numpy.int64)
         elif all(issubclass(obj_type, (sympy.Integer, sympy.Float)) for obj_type in obj_types):
-            result = vector_result.astype(float)
+            vector_result = vector_result.astype(float)
         else:
             raise ValueError("Could not parse vector result", vector_result)
     return vector_result
@@ -98,7 +100,7 @@ def _parse_evaluate_numeric_arguments(self, eval_args: Mapping[str, Number]) ->
                 # we forward qupulse errors, I down like this
                 raise
             else:
-                raise ExpressionVariableMissingException(key_error.args[0], self) from key_error
+                raise qupulse.expressions.ExpressionVariableMissingException(key_error.args[0], self) from key_error
 
     def evaluate_in_scope(self, scope: Mapping) -> Union[Number, numpy.ndarray]:
         """Evaluate the expression by taking the variables from the given scope (typically of type Scope but it can be
@@ -129,7 +131,7 @@ def evaluate_symbolic(self, substitutions: Mapping[Any, Any]) -> 'Expression':
     def _evaluate_to_time_dependent(self, scope: Mapping) -> Union['Expression', Number, numpy.ndarray]:
         try:
             return self.evaluate_numeric(**scope, t=sympy.symbols('t'))
-        except NonNumericEvaluation as non_num:
+        except qupulse.expressions.NonNumericEvaluation as non_num:
             return ExpressionScalar(non_num.non_numeric_result)
         except TypeError:
             return self.evaluate_symbolic(scope)
@@ -212,7 +214,7 @@ def evaluate_in_scope(self, scope: Mapping) -> numpy.ndarray:
         try:
             return _parse_evaluate_numeric_vector(result)
         except ValueError as err:
-            raise NonNumericEvaluation(self, result, scope) from err
+            raise qupulse.expressions.NonNumericEvaluation(self, result, scope) from err
 
     def get_serialization_data(self) -> Sequence[str]:
         serialized_items = list(map(get_most_simple_representation, self._expression_items))
@@ -399,6 +401,12 @@ def __truediv__(self, other: Union['ExpressionScalar', Number, sympy.Expr]) -> '
     def __rtruediv__(self, other: Union['ExpressionScalar', Number, sympy.Expr]) -> 'ExpressionScalar':
         return self.make(self._sympified_expression.__rtruediv__(self._extract_sympified(other)))
 
+    def __floordiv__(self, other: Union['ExpressionScalar', Number, sympy.Expr]) -> 'ExpressionScalar':
+        return self.make(self._sympified_expression.__floordiv__(self._extract_sympified(other)))
+
+    def __rfloordiv__(self, other: Union['ExpressionScalar', Number, sympy.Expr]) -> 'ExpressionScalar':
+        return self.make(self._sympified_expression.__rfloordiv__(self._extract_sympified(other)))
+
     def __neg__(self) -> 'ExpressionScalar':
         return self.make(self._sympified_expression.__neg__())
 
@@ -444,7 +452,7 @@ def evaluate_with_exact_rationals(self, scope: Mapping) -> Union[Number, numpy.n
         try:
             return _parse_evaluate_numeric(result)
         except ValueError as err:
-            raise NonNumericEvaluation(self, result, scope) from err
+            raise qupulse.expressions.NonNumericEvaluation(self, result, scope) from err
 
     def evaluate_in_scope(self, scope: Mapping) -> Union[Number, numpy.ndarray]:
         parsed_kwargs = self._parse_evaluate_numeric_arguments(scope)
@@ -453,50 +461,4 @@ def evaluate_in_scope(self, scope: Mapping) -> Union[Number, numpy.ndarray]:
         try:
             return _parse_evaluate_numeric(result)
         except ValueError as err:
-            raise NonNumericEvaluation(self, result, scope) from err
-
-
-class ExpressionVariableMissingException(Exception):
-    """An exception indicating that a variable value was not provided during expression evaluation.
-
-    See also:
-         qupulse.expressions.Expression
-    """
-
-    def __init__(self, variable: str, expression: Expression) -> None:
-        super().__init__()
-        self.variable = variable
-        self.expression = expression
-
-    def __str__(self) -> str:
-        return "Could not evaluate <{}>: A value for variable <{}> is missing!".format(
-            str(self.expression), self.variable)
-
-
-class NonNumericEvaluation(Exception):
-    """An exception that is raised if the result of evaluate_numeric is not a number.
-
-    See also:
-        qupulse.expressions.Expression.evaluate_numeric
-    """
-
-    def __init__(self, expression: Expression, non_numeric_result: Any, call_arguments: Mapping):
-        self.expression = expression
-        self.non_numeric_result = non_numeric_result
-        self.call_arguments = call_arguments
-
-    def __str__(self) -> str:
-        if isinstance(self.non_numeric_result, numpy.ndarray):
-            dtype = self.non_numeric_result.dtype
-
-            if dtype == numpy.dtype('O'):
-                dtypes = set(map(type, self.non_numeric_result.flat))
-                "The result of evaluate_numeric is an array with the types {} " \
-                "which is not purely numeric".format(dtypes)
-        else:
-            dtype = type(self.non_numeric_result)
-        return "The result of evaluate_numeric is of type {} " \
-               "which is not a number".format(dtype)
-
-
-ExpressionLike = TypeVar('ExpressionLike', str, Number, sympy.Expr, ExpressionScalar)
+            raise qupulse.expressions.NonNumericEvaluation(self, result, scope) from err
diff --git a/qupulse/expressions/wrapper.py b/qupulse/expressions/wrapper.py
new file mode 100644
index 000000000..1052c1174
--- /dev/null
+++ b/qupulse/expressions/wrapper.py
@@ -0,0 +1,117 @@
+"""This module contains the function :py:``make_wrappers`` to define wrapper classes for expression protocol implementations
+which only implements methods of the protocol.
+It is used for finding code that relies on expression implementation details."""
+
+import math
+from typing import Sequence, Any, Mapping, Union, Tuple
+from numbers import Real
+
+import numpy as np
+
+from qupulse.expressions import protocol, sympy
+
+
+def make_wrappers(expr: type, expr_scalar: type, expr_vector: type) -> Tuple[type, type, type]:
+    """Create wrappers for expression base, scalar and vector types that only expose the methods defined in the
+    corresponding expression protocol classes.
+
+    The vector is currently not implemented.
+
+    Args:
+        expr: Expression base type of the implementation
+        expr_scalar: Expression scalar type of the implementation
+        expr_vector: Expression vector type of the implementation
+
+    Returns:
+        A tuple of (base, scalar, vector) types that wrap the given types.
+    """
+
+    class ExpressionWrapper(protocol.Expression):
+        def __init__(self, x):
+            self._wrapped: protocol.Expression = expr(x)
+
+        @classmethod
+        def make(cls, expression_or_dict, numpy_evaluation=None) -> 'ExpressionWrapper':
+            return cls(expression_or_dict)
+
+        @property
+        def underlying_expression(self) -> Any:
+            return self._wrapped.underlying_expression
+
+        def __hash__(self) -> int:
+            return hash(self._wrapped)
+
+        def __eq__(self, other):
+            return self._wrapped == getattr(other, '_wrapped', other)
+
+        @property
+        def variables(self) -> Sequence[str]:
+            return self._wrapped.variables
+
+        def evaluate_in_scope(self, scope: Mapping) -> Union[Real, np.ndarray]:
+            return self._wrapped.evaluate_in_scope(scope)
+
+        def evaluate_symbolic(self, substitutions: Mapping[str, Any]) -> 'ExpressionWrapper':
+            """Substitute a part of the expression for another"""
+            return ExpressionWrapper(self._wrapped.evaluate_symbolic(substitutions))
+
+        def evaluate_time_dependent(self, scope: Mapping) -> Union['Expression', Real, np.ndarray]:
+            """Evaluate to a time dependent expression or a constant."""
+            return self._wrapped.evaluate_time_dependent(scope)
+
+        def get_serialization_data(self):
+            return self._wrapped.get_serialization_data()
+
+    class ExpressionScalarWrapper(ExpressionWrapper, protocol.ExpressionScalar):
+        def __init__(self, x):
+            ExpressionWrapper.__init__(self, 0)
+            self._wrapped: protocol.ExpressionScalar = expr_scalar(x)
+
+        # Scalar
+        def __add__(self, other):
+            return ExpressionScalarWrapper(self._wrapped + getattr(other, '_wrapped', other))
+
+        def __sub__(self, other):
+            return ExpressionScalarWrapper(self._wrapped - getattr(other, '_wrapped', other))
+
+        def __mul__(self, other):
+            return ExpressionScalarWrapper(self._wrapped * getattr(other, '_wrapped', other))
+
+        def __truediv__(self, other):
+            return ExpressionScalarWrapper(self._wrapped / getattr(other, '_wrapped', other))
+
+        def __floordiv__(self, other):
+            return ExpressionScalarWrapper(self._wrapped // getattr(other, '_wrapped', other))
+
+        def __ceil__(self):
+            return ExpressionScalarWrapper(math.ceil(self._wrapped))
+
+        def __floor__(self):
+            return ExpressionScalarWrapper(math.floor(self._wrapped))
+
+        def __float__(self):
+            return float(self._wrapped)
+
+        def __int__(self):
+            return int(self._wrapped)
+
+        def __abs__(self):
+            return ExpressionScalarWrapper(abs(self._wrapped))
+
+        # Ordered
+        def __lt__(self, other):
+            return self._wrapped < getattr(other, '_wrapped', other)
+
+        def __le__(self, other):
+            return self._wrapped <= getattr(other, '_wrapped', other)
+
+        def __gt__(self, other):
+            return self._wrapped > getattr(other, '_wrapped', other)
+
+        def __ge__(self, other):
+            return self._wrapped >= getattr(other, '_wrapped', other)
+
+    class ExpressionVectorWrapper(ExpressionWrapper):
+        pass
+
+    return ExpressionWrapper, ExpressionScalarWrapper, ExpressionVectorWrapper
diff --git a/qupulse/hardware/awgs/base.py b/qupulse/hardware/awgs/base.py
index 498788038..5b1bb7c74 100644
--- a/qupulse/hardware/awgs/base.py
+++ b/qupulse/hardware/awgs/base.py
@@ -9,13 +9,17 @@
 
 from abc import abstractmethod
 from numbers import Real
-from typing import Set, Tuple, Callable, Optional, Mapping, Sequence, List
+from typing import Set, Tuple, Callable, Optional, Mapping, Sequence, List, Union, NamedTuple
 from collections import OrderedDict
+from enum import Enum
+# from itertools import chain
 
 from qupulse.hardware.util import get_sample_times, not_none_indices
 from qupulse.utils.types import ChannelID
-from qupulse._program._loop import Loop
-from qupulse._program.waveforms import Waveform
+from qupulse.program.linspace import LinSpaceNode, LinSpaceArbitraryWaveform, to_increment_commands, Command, \
+    Increment, Set as LSPSet, LoopLabel, LoopJmp, Wait, Play
+from qupulse.program.loop import Loop
+from qupulse.program.waveforms import Waveform
 from qupulse.comparable import Comparable
 from qupulse.utils.types import TimeType
 
@@ -162,17 +166,32 @@ def __str__(self) -> str:
                " Use force to overwrite.".format(self.name)
 
 
+AllowedProgramTypes = Union[Loop,Sequence[LinSpaceNode],]
+
+class _ProgramType(Enum):
+    FSP = -1
+    Loop = 0
+    Linspace = 1
+
+
+class ChannelTransformation(NamedTuple):
+    amplitude: float
+    offset: float
+    voltage_transformation: Optional[callable]
+
+
 class ProgramEntry:
     """This is a helper class for implementing awgs drivers. A driver can subclass it to help organizing sampled
     waveforms"""
-    def __init__(self, loop: Loop,
+    def __init__(self, program: AllowedProgramTypes,
                  channels: Tuple[Optional[ChannelID], ...],
                  markers: Tuple[Optional[ChannelID], ...],
                  amplitudes: Tuple[float, ...],
                  offsets: Tuple[float, ...],
                  voltage_transformations: Tuple[Optional[Callable], ...],
                  sample_rate: TimeType,
-                 waveforms: Sequence[Waveform] = None):
+                 waveforms: Sequence[Waveform] = None,
+                 program_type: _ProgramType = _ProgramType.Loop):
         """
 
         Args:
@@ -195,17 +214,42 @@ def __init__(self, loop: Loop,
         self._voltage_transformations = tuple(voltage_transformations)
 
         self._sample_rate = sample_rate
-
-        self._loop = loop
-
+        
+        self._program_type = program_type
+        self._program = program
+        
+        if program_type == _ProgramType.Linspace:
+            self._transformed_commands = self._transform_linspace_commands(to_increment_commands(program))
+        
         if waveforms is None:
-            waveforms = OrderedDict((node.waveform, None)
-                                    for node in loop.get_depth_first_iterator() if node.is_leaf()).keys()
+            if program_type is _ProgramType.Loop:
+                    waveforms = OrderedDict((node.waveform, None)
+                                        for node in program.get_depth_first_iterator() if node.is_leaf()).keys()
+            elif program_type is _ProgramType.Linspace:
+                    #not so clean
+                    #TODO: also marker handling not optimal
+                    waveforms = OrderedDict((command.waveform, None)
+                                        for command in self._transformed_commands if isinstance(command,Play)).keys()
+            else:
+                raise NotImplementedError()
+                    
         if waveforms:
             self._waveforms = OrderedDict(zip(waveforms, self._sample_waveforms(waveforms)))
         else:
             self._waveforms = OrderedDict()
-
+    
+    @property
+    def _loop(self,) -> Loop:
+        if self._program_type not in (_ProgramType.Loop, _ProgramType.FSP):
+            raise AttributeError("The _loop attribute can only be get on loop-like program entries.")
+        return self._program
+    
+    @_loop.setter
+    def _loop(self, program: Loop):
+        if self._program_type not in (_ProgramType.Loop, _ProgramType.FSP):
+            raise AttributeError("The _loop attribute can only be set on loop-like program entries.")
+        self._program = program
+    
     def _sample_empty_channel(self, time: numpy.ndarray) -> Optional[numpy.ndarray]:
         """Override this in derived class to change how empty channels are handled"""
         return None
@@ -214,6 +258,37 @@ def _sample_empty_marker(self, time: numpy.ndarray) -> Optional[numpy.ndarray]:
         """Override this in derived class to change how empty channels are handled"""
         return None
 
+    def _channel_transformations(self) -> Mapping[ChannelID, ChannelTransformation]:
+        return {ch: ChannelTransformation(amplitude, offset, trafo)
+                for ch, trafo, amplitude, offset in zip(self._channels,
+                                                        self._voltage_transformations,
+                                                        self._amplitudes,
+                                                        self._offsets)}
+    
+    def _transform_linspace_commands(self, command_list: List[Command]) -> List[Command]:
+        # all commands = Union[Increment, Set, LoopLabel, LoopJmp, Wait, Play]
+        trafos_by_channel_idx = list(self._channel_transformations().values())
+
+        for command in command_list:
+            if isinstance(command, (LoopLabel, LoopJmp, Play, Wait)):
+                # play is handled by transforming the sampled waveform
+                continue
+            elif isinstance(command, Increment):
+                ch_trafo = trafos_by_channel_idx[command.channel]
+                if ch_trafo.voltage_transformation:
+                    raise RuntimeError("Cannot apply a voltage transformation to a linspace increment command")
+                command.value /= ch_trafo.amplitude
+            elif isinstance(command, LSPSet):
+                ch_trafo = trafos_by_channel_idx[command.channel]
+                if ch_trafo.voltage_transformation:
+                    command.value = float(ch_trafo.voltage_transformation(command.value))
+                command.value -= ch_trafo.offset
+                command.value /= ch_trafo.amplitude
+            else:        
+                raise NotImplementedError(command)
+        
+        return command_list
+    
     def _sample_waveforms(self, waveforms: Sequence[Waveform]) -> List[Tuple[Tuple[numpy.ndarray, ...],
                                                                              Tuple[numpy.ndarray, ...]]]:
         sampled_waveforms = []
diff --git a/qupulse/hardware/awgs/dummy.py b/qupulse/hardware/awgs/dummy.py
new file mode 100644
index 000000000..249497483
--- /dev/null
+++ b/qupulse/hardware/awgs/dummy.py
@@ -0,0 +1,73 @@
+from typing import Tuple, Set
+
+from .base import AWG, ProgramOverwriteException
+
+class DummyAWG(AWG):
+    def __init__(self,
+                 sample_rate: float=10,
+                 output_range: Tuple[float, float]=(-5, 5),
+                 num_channels: int=1,
+                 num_markers: int=1) -> None:
+        """Dummy AWG for automated testing, debugging and usage in examples.
+
+        Args:
+            sample_rate (float): The sample rate of the dummy. (default = 10)
+            output_range (float, float): A (min,max)-tuple of possible output values.
+                (default = (-5,5)).
+        """
+        super().__init__(identifier="DummyAWG{0}".format(id(self)))
+
+        self._programs = {} # contains program names and programs
+        self._sample_rate = sample_rate
+        self._output_range = output_range
+        self._num_channels = num_channels
+        self._num_markers = num_markers
+        self._channels = ('default',)
+        self._armed = None
+
+    def set_volatile_parameters(self, program_name: str, parameters):
+        raise NotImplementedError()
+
+    def upload(self, name, program, channels, markers, voltage_transformation, force=False) -> None:
+        if name in self.programs:
+            if not force:
+                raise ProgramOverwriteException(name)
+            else:
+                self.remove(name)
+                self.upload(name, program, channels, markers, voltage_transformation)
+        else:
+            self._programs[name] = (program, channels, markers, voltage_transformation)
+
+    def remove(self, name) -> None:
+        if name in self.programs:
+            self._programs.pop(name)
+
+    def clear(self) -> None:
+        self._programs = {}
+
+    def arm(self, name: str) -> None:
+        self._armed = name
+
+    @property
+    def programs(self) -> Set[str]:
+        return set(self._programs.keys())
+
+    @property
+    def output_range(self) -> Tuple[float, float]:
+        return self._output_range
+
+    @property
+    def identifier(self) -> str:
+        return "DummyAWG{0}".format(id(self))
+
+    @property
+    def sample_rate(self) -> float:
+        return self._sample_rate
+
+    @property
+    def num_channels(self):
+        return self._num_channels
+
+    @property
+    def num_markers(self):
+        return self._num_markers
diff --git a/qupulse/hardware/awgs/tabor.py b/qupulse/hardware/awgs/tabor.py
index 6e661bc0c..90e03866e 100644
--- a/qupulse/hardware/awgs/tabor.py
+++ b/qupulse/hardware/awgs/tabor.py
@@ -11,11 +11,11 @@
 import numpy as np
 
 from qupulse.utils.types import ChannelID
-from qupulse._program._loop import Loop, make_compatible
-from qupulse.hardware.util import voltage_to_uint16, find_positions, traced
+from qupulse.program.loop import Loop, make_compatible
+from qupulse.hardware.util import voltage_to_uint16, traced
 from qupulse.hardware.awgs.base import AWG, AWGAmplitudeOffsetHandling
 from qupulse._program.tabor import TaborSegment, TaborException, TaborProgram, PlottableProgram, TaborSequencing,\
-    make_combined_wave
+    make_combined_wave, find_place_for_segments_in_memory
 
 
 __all__ = ['TaborAWGRepresentation', 'TaborChannelPair']
@@ -543,87 +543,16 @@ def clear(self) -> None:
         self.change_armed_program(None)
 
     def _find_place_for_segments_in_memory(self, segments: Sequence, segment_lengths: Sequence) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
-        """
-        1. Find known segments
-        2. Find empty spaces with fitting length
-        3. Find empty spaces with bigger length
-        4. Amend remaining segments
-        :param segments:
-        :param segment_lengths:
-        :return:
-        """
-        segment_hashes = np.fromiter((hash(segment) for segment in segments), count=len(segments), dtype=np.int64)
-
-        waveform_to_segment = find_positions(self._segment_hashes, segment_hashes)
-
-        # separate into known and unknown
-        unknown = (waveform_to_segment == -1)
-        known = ~unknown
-
-        known_pos_in_memory = waveform_to_segment[known]
-
-        assert len(known_pos_in_memory) == 0 or np.all(self._segment_hashes[known_pos_in_memory] == segment_hashes[known])
-
-        new_reference_counter = self._segment_references.copy()
-        new_reference_counter[known_pos_in_memory] += 1
-
-        to_upload_size = np.sum(segment_lengths[unknown] + 16)
-        free_points_in_total = self.total_capacity - np.sum(self._segment_capacity[self._segment_references > 0])
-        if free_points_in_total < to_upload_size:
-            raise MemoryError('Not enough free memory',
-                              free_points_in_total,
-                              to_upload_size,
-                              self._free_points_in_total)
-
-        to_amend = cast(np.ndarray, unknown)
-        to_insert = np.full(len(segments), fill_value=-1, dtype=np.int64)
-
-        reserved_indices = np.flatnonzero(new_reference_counter > 0)
-        first_free = reserved_indices[-1] + 1 if len(reserved_indices) else 0
+        segment_hashes = np.fromiter((hash(segment) for segment in segments), dtype=np.int64, count=len(segments))
 
-        free_segments = new_reference_counter[:first_free] == 0
-        free_segment_count = np.sum(free_segments)
-
-        # look for a free segment place with the same length
-        for segment_idx in np.flatnonzero(to_amend):
-            if free_segment_count == 0:
-                break
-
-            pos_of_same_length = np.logical_and(free_segments, segment_lengths[segment_idx] == self._segment_capacity[:first_free])
-            idx_same_length = np.argmax(pos_of_same_length)
-            if pos_of_same_length[idx_same_length]:
-                free_segments[idx_same_length] = False
-                free_segment_count -= 1
-
-                to_amend[segment_idx] = False
-                to_insert[segment_idx] = idx_same_length
-
-        # try to find places that are larger than the segments to fit in starting with the large segments and large
-        # free spaces
-        segment_indices = np.flatnonzero(to_amend)[np.argsort(segment_lengths[to_amend])[::-1]]
-        capacities = self._segment_capacity[:first_free]
-        for segment_idx in segment_indices:
-            free_capacities = capacities[free_segments]
-            free_segments_indices = np.flatnonzero(free_segments)[np.argsort(free_capacities)[::-1]]
-
-            if len(free_segments_indices) == 0:
-                break
-
-            fitting_segment = np.argmax((free_capacities >= segment_lengths[segment_idx])[::-1])
-            fitting_segment = free_segments_indices[fitting_segment]
-            if self._segment_capacity[fitting_segment] >= segment_lengths[segment_idx]:
-                free_segments[fitting_segment] = False
-                to_amend[segment_idx] = False
-                to_insert[segment_idx] = fitting_segment
-
-        free_points_at_end = self.total_capacity - np.sum(self._segment_capacity[:first_free])
-        if np.sum(segment_lengths[to_amend] + 16) > free_points_at_end:
-            raise MemoryError('Fragmentation does not allow upload.',
-                              np.sum(segment_lengths[to_amend] + 16),
-                              free_points_at_end,
-                              self._free_points_at_end)
-
-        return waveform_to_segment, to_amend, to_insert
+        return find_place_for_segments_in_memory(
+            current_segment_hashes=self._segment_hashes,
+            current_segment_capacities=self._segment_capacity,
+            current_segment_references=self._segment_references,
+            total_capacity=self.total_capacity,
+            new_segment_lengths=segment_lengths,
+            new_segment_hashes=segment_hashes
+        )
 
     @with_select
     @with_configuration_guard
@@ -953,3 +882,4 @@ def reset_device(self):
             self.device.reset()
         elif isinstance(self.device, TaborChannelPair):
             self.device.clear()
+
diff --git a/qupulse/hardware/awgs/tektronix.py b/qupulse/hardware/awgs/tektronix.py
index 5865388e6..eaaa7a360 100644
--- a/qupulse/hardware/awgs/tektronix.py
+++ b/qupulse/hardware/awgs/tektronix.py
@@ -11,8 +11,8 @@
 
 from qupulse.hardware.awgs.base import AWG, AWGAmplitudeOffsetHandling, ProgramOverwriteException
 from qupulse import ChannelID
-from qupulse._program._loop import Loop, make_compatible
-from qupulse._program.waveforms import Waveform as QuPulseWaveform
+from qupulse.program.loop import Loop, make_compatible
+from qupulse.program.waveforms import Waveform as QuPulseWaveform
 from qupulse.utils.types import TimeType
 from qupulse.hardware.util import voltage_to_uint16, get_sample_times, traced
 from qupulse.utils import pairwise
diff --git a/qupulse/hardware/awgs/zihdawg.py b/qupulse/hardware/awgs/zihdawg.py
index 6d6972a90..519051c38 100644
--- a/qupulse/hardware/awgs/zihdawg.py
+++ b/qupulse/hardware/awgs/zihdawg.py
@@ -1,1133 +1,21 @@
-import numbers
-from pathlib import Path
-import functools
-from typing import Tuple, Set, Callable, Optional, Mapping, Generator, Union, Sequence, Dict
-from enum import Enum
-import weakref
-import logging
-import warnings
-import pathlib
-import hashlib
+import sys
 import argparse
-import re
-from abc import abstractmethod
-
-try:
-    # zhinst fires a DeprecationWarning from its own code in some versions...
-    with warnings.catch_warnings():
-        warnings.simplefilter('ignore', DeprecationWarning)
-        import zhinst.utils
-except ImportError:
-    warnings.warn('Zurich Instruments LabOne python API is distributed via the Python Package Index. Install with pip.')
-    raise
-
-try:
-    from zhinst import core as zhinst_core
-except ImportError:
-    # backward compability
-    from zhinst import ziPython as zhinst_core
-
-import time
-
-from qupulse.utils.types import ChannelID, TimeType, time_from_float
-from qupulse._program._loop import Loop, make_compatible
-from qupulse._program.seqc import HDAWGProgramManager, UserRegister, WaveformFileSystem
-from qupulse.hardware.awgs.base import AWG, ChannelNotFoundException, AWGAmplitudeOffsetHandling
-from qupulse.hardware.util import traced
-
-
-logger = logging.getLogger('qupulse.hdawg')
-
-
-def valid_channel(function_object):
-    """Check if channel is a valid AWG channels. Expects channel to be 2nd argument after self."""
-    @functools.wraps(function_object)
-    def valid_fn(*args, **kwargs):
-        if len(args) < 2:
-            raise HDAWGTypeError('Channel is an required argument.')
-        channel = args[1]  # Expect channel to be second positional argument after self.
-        if channel not in range(1, 9):
-            raise ChannelNotFoundException(channel)
-        value = function_object(*args, **kwargs)
-        return value
-    return valid_fn
-
-
-def _amplitude_scales(api_session, serial: str):
-    return tuple(
-        api_session.getDouble(f'/{serial}/awgs/{ch // 2:d}/outputs/{ch % 2:d}/amplitude')
-        for ch in range(8)
-    )
-
-def _sigout_double(api_session, prop: str, serial: str, channel: int, value: float = None) -> float:
-    """Query channel offset voltage and optionally set it."""
-    node_path = f'/{serial}/sigouts/{channel-1:d}/{prop}'
-    if value is not None:
-        api_session.setDouble(node_path, value)
-        api_session.sync()  # Global sync: Ensure settings have taken effect on the device.
-    return api_session.getDouble(node_path)
-
-def _sigout_range(api_session, serial: str, channel: int, voltage: float = None) -> float:
-    return _sigout_double(api_session, 'range', serial, channel, voltage)
-
-def _sigout_offset(api_session, serial: str, channel: int, voltage: float = None) -> float:
-    return _sigout_double(api_session, 'offset', serial, channel, voltage)
-
-def _sigout_on(api_session, serial: str, channel: int, value: bool = None) -> bool:
-    """Query channel signal output status (enabled/disabled) and optionally set it. Corresponds to front LED."""
-    node_path = f'/{serial}/sigouts/{channel-1:d}/on'
-    if value is not None:
-        api_session.setInt(node_path, value)
-        api_session.sync()  # Global sync: Ensure settings have taken effect on the device.
-    return bool(api_session.getInt(node_path))
-
-
-@traced
-class HDAWGRepresentation:
-    """HDAWGRepresentation represents an HDAWG8 instruments and manages a LabOne data server api session. A data server
-    must be running and the device be discoverable. Channels are per default grouped into pairs."""
-
-    def __init__(self, device_serial: str = None,
-                 device_interface: str = '1GbE',
-                 data_server_addr: str = 'localhost',
-                 data_server_port: int = 8004,
-                 api_level_number: int = 6,
-                 reset: bool = False,
-                 timeout: float = 20,
-                 grouping: 'HDAWGChannelGrouping' = None) -> None:
-        """
-        :param device_serial:     Device serial that uniquely identifies this device to the LabOne data server
-        :param device_interface:  Either '1GbE' for ethernet or 'USB'
-        :param data_server_addr:  Data server address. Must be already running. Default: localhost
-        :param data_server_port:  Data server port. Default: 8004 for HDAWG, MF and UHF devices
-        :param api_level_number:  Version of API to use for the session, higher number, newer. Default: 6 most recent
-        :param reset:             Reset device before initialization
-        :param timeout:           Timeout in seconds for uploading
-        """
-        self._api_session = zhinst_core.ziDAQServer(data_server_addr, data_server_port, api_level_number)
-        assert zhinst.utils.api_server_version_check(self.api_session)  # Check equal data server and api version.
-        self.api_session.connectDevice(device_serial, device_interface)
-        self.default_timeout = timeout
-        self._dev_ser = device_serial
-
-        if reset:
-            # Create a base configuration: Disable all available outputs, awgs, demods, scopes,...
-            zhinst.utils.disable_everything(self.api_session, self.serial)
-
-        self._initialize()
-
-        waveform_path = pathlib.Path(self.api_session.awgModule().getString('directory'), 'awg', 'waves')
-        self._waveform_file_system = WaveformFileSystem.get_waveform_file_system(waveform_path)
-        self._channel_groups: Dict[HDAWGChannelGrouping, Tuple[HDAWGChannelGroup, ...]] = {}
-
-        # TODO: lookup method to find channel count
-        n_channels = 8
-
-        for grouping in HDAWGChannelGrouping:
-            group_size = grouping.group_size()
-            if group_size is None:
-                # MDS
-                groups = [
-                    MDSChannelGroup(self.group_name(0, None), self.default_timeout)
-                ]
-            else:
-                groups = []
-                for group_idx in range(n_channels // group_size):
-                    groups.append(SingleDeviceChannelGroup(group_idx, group_size,
-                                                           identifier=self.group_name(group_idx, group_size),
-                                                           timeout=self.default_timeout))
-            self._channel_groups[grouping] = tuple(groups)
-
-        if grouping is None:
-            grouping = self.channel_grouping
-        # activates channel groups
-        self.channel_grouping = grouping
-
-    @property
-    def waveform_file_system(self) -> WaveformFileSystem:
-        return self._waveform_file_system
-
-    @property
-    def channel_tuples(self) -> Tuple['HDAWGChannelGroup', ...]:
-        return self._get_groups(self.channel_grouping)
-
-    @property
-    def channel_pair_AB(self) -> 'HDAWGChannelGroup':
-        return self._channel_groups[HDAWGChannelGrouping.CHAN_GROUP_4x2][0]
-
-    @property
-    def channel_pair_CD(self) -> 'HDAWGChannelGroup':
-        return self._channel_groups[HDAWGChannelGrouping.CHAN_GROUP_4x2][1]
-
-    @property
-    def channel_pair_EF(self) -> 'HDAWGChannelGroup':
-        return self._channel_groups[HDAWGChannelGrouping.CHAN_GROUP_4x2][2]
-
-    @property
-    def channel_pair_GH(self) -> 'HDAWGChannelGroup':
-        return self._channel_groups[HDAWGChannelGrouping.CHAN_GROUP_4x2][3]
-
-    @property
-    def api_session(self) -> zhinst_core.ziDAQServer:
-        return self._api_session
-
-    @property
-    def serial(self) -> str:
-        return self._dev_ser
-
-    def _initialize(self) -> None:
-        settings = [(f'/{self.serial}/awgs/*/userregs/*', 0),  # Reset all user registers to 0.
-                    (f'/{self.serial}/*/single', 1)]  # Single execution mode of sequence.
-        for ch in range(0, 8):  # Route marker 1 signal for each channel to marker output.
-            if ch % 2 == 0:
-                output = HDAWGTriggerOutSource.OUT_1_MARK_1.value
-            else:
-                output = HDAWGTriggerOutSource.OUT_1_MARK_2.value
-            settings.append(['/{}/triggers/out/{}/source'.format(self.serial, ch), output])
-
-        self.api_session.set(settings)
-        self.api_session.sync()  # Global sync: Ensure settings have taken effect on the device.
-
-    def reset(self) -> None:
-        zhinst.utils.disable_everything(self.api_session, self.serial)
-        self._initialize()
-        for tuple in self.channel_tuples:
-            tuple.clear()
-        self.api_session.set([
-            (f'/{self.serial}/awgs/*/time', 0),
-            (f'/{self.serial}/sigouts/*/range', HDAWGVoltageRange.RNG_1V.value),
-            (f'/{self.serial}/awgs/*/outputs/*/amplitude', 1.0),
-            (f'/{self.serial}/outputs/*/modulation/mode', HDAWGModulationMode.OFF.value),
-        ])
-
-        # marker outputs
-        marker_settings = []
-        for ch in range(0, 8):  # Route marker 1 signal for each channel to marker output.
-            if ch % 2 == 0:
-                output = HDAWGTriggerOutSource.OUT_1_MARK_1.value
-            else:
-                output = HDAWGTriggerOutSource.OUT_1_MARK_2.value
-            marker_settings.append([f'/{self.serial}/triggers/out/{ch}/source', output])
-        self.api_session.set(marker_settings)
-        self.api_session.sync()
-
-    def group_name(self, group_idx, group_size) -> str:
-        if group_size is None:
-            return f'{self.serial}_MDS'
-        return str(self.serial) + '_' + 'ABCDEFGH'[group_idx*group_size:][:group_size]
-
-    def _get_groups(self, grouping: 'HDAWGChannelGrouping') -> Tuple['HDAWGChannelGroup', ...]:
-        try:
-            return self._channel_groups[grouping]
-        except KeyError:
-            # python reload...
-            for grouping_key, group in self._channel_groups.items():
-                if grouping_key.value == grouping.value:
-                    return group
-            else:
-                raise
-
-    @property
-    def channel_grouping(self) -> 'HDAWGChannelGrouping':
-        grouping = self.api_session.getInt(f'/{self.serial}/SYSTEM/AWG/CHANNELGROUPING')
-        return HDAWGChannelGrouping(grouping)
-
-    @channel_grouping.setter
-    def channel_grouping(self, channel_grouping: 'HDAWGChannelGrouping'):
-        # ipython reload ...
-        if not type(channel_grouping).__name__ == 'HDAWGChannelGrouping':
-            raise HDAWGTypeError('Channel grouping must be an enum of type "HDAWGChannelGrouping" to avoid confusions '
-                                 'between enum value and group size.')
-        old_channel_grouping = self.channel_grouping
-        if old_channel_grouping != channel_grouping:
-            self.api_session.setInt(f'/{self.serial}/AWGS/*/ENABLE', 0)
-            self.api_session.setInt(f'/{self.serial}/SYSTEM/AWG/CHANNELGROUPING', channel_grouping.value)
-            # disable old groups
-            for group in self._get_groups(old_channel_grouping):
-                group.disconnect_group()
-
-        if channel_grouping.value == HDAWGChannelGrouping.MDS.value and not self._is_mds_master():
-            # do not connect channel group
-            return
-
-        for group in self._get_groups(channel_grouping):
-            if not group.is_connected():
-                group.connect_group(self)
-
-    @valid_channel
-    def offset(self, channel: int, voltage: float = None) -> float:
-        """Query channel offset voltage and optionally set it."""
-        return _sigout_offset(self.api_session, self.serial, channel, voltage)
-
-    @valid_channel
-    def range(self, channel: int, voltage: float = None) -> float:
-        """Query channel voltage range and optionally set it. The instruments selects the next higher available range.
-        This is the one-sided range Vp. Total range: -Vp...Vp"""
-        return _sigout_range(self.api_session, self.serial, channel, voltage)
-
-    @valid_channel
-    def output(self, channel: int, status: bool = None) -> bool:
-        """Query channel signal output status (enabled/disabled) and optionally set it. Corresponds to front LED."""
-        return _sigout_on(self.api_session, self.serial, channel, status)
-
-    def get_status_table(self):
-        """Return node tree of instrument with all important settings, as well as each channel group as tuple."""
-        return (self.api_session.get('/{}/*'.format(self.serial)),
-                self.channel_pair_AB.awg_module.get('awgModule/*'),
-                self.channel_pair_CD.awg_module.get('awgModule/*'),
-                self.channel_pair_EF.awg_module.get('awgModule/*'),
-                self.channel_pair_GH.awg_module.get('awgModule/*'))
-
-    def _get_mds_group_idx(self) -> Optional[int]:
-        idx = 0
-        while True:
-            try:
-                if self.serial in self.api_session.getString(f'/ZI/MDS/GROUPS/{idx}/DEVICES'):
-                    return idx
-            except RuntimeError:
-                break
-            idx += 1
-
-    def _is_mds_master(self) -> Optional[bool]:
-        idx = 0
-        while True:
-            try:
-                devices =  self.api_session.getString(f'/ZI/MDS/GROUPS/{idx}/DEVICES').split(',')
-            except RuntimeError:
-                break
-
-            if self.serial in devices:
-                return devices[0] == self.serial
-            idx += 1
-
-    def __repr__(self):
-        return f"{type(self).__name__}({self.serial}, ... {self.api_session})"
-
-
-class HDAWGTriggerOutSource(Enum):
-    """Assign a signal to a marker output. This is per AWG Core."""
-    AWG_TRIG_1 = 0  # Trigger output assigned to AWG trigger 1, controlled by AWG sequencer commands.
-    AWG_TRIG_2 = 1  # Trigger output assigned to AWG trigger 2, controlled by AWG sequencer commands.
-    AWG_TRIG_3 = 2  # Trigger output assigned to AWG trigger 3, controlled by AWG sequencer commands.
-    AWG_TRIG_4 = 3  # Trigger output assigned to AWG trigger 4, controlled by AWG sequencer commands.
-    OUT_1_MARK_1 = 4  # Trigger output assigned to output 1 marker 1.
-    OUT_1_MARK_2 = 5  # Trigger output assigned to output 1 marker 2.
-    OUT_2_MARK_1 = 6  # Trigger output assigned to output 2 marker 1.
-    OUT_2_MARK_2 = 7  # Trigger output assigned to output 2 marker 2.
-    TRIG_IN_1 = 8  # Trigger output assigned to trigger inout 1.
-    TRIG_IN_2 = 9  # Trigger output assigned to trigger inout 2.
-    TRIG_IN_3 = 10  # Trigger output assigned to trigger inout 3.
-    TRIG_IN_4 = 11  # Trigger output assigned to trigger inout 4.
-    TRIG_IN_5 = 12  # Trigger output assigned to trigger inout 5.
-    TRIG_IN_6 = 13  # Trigger output assigned to trigger inout 6.
-    TRIG_IN_7 = 14  # Trigger output assigned to trigger inout 7.
-    TRIG_IN_8 = 15  # Trigger output assigned to trigger inout 8.
-    HIGH = 17 # Trigger output is set to high.
-    LOW = 18 # Trigger output is set to low.
-
-
-class HDAWGChannelGrouping(Enum):
-    """How many independent sequencers should run on the AWG and how the outputs should be grouped by sequencer."""
-    MDS = -1            # All channels that are in the current multi device synchronized group
-    CHAN_GROUP_4x2 = 0  # 4x2 with HDAWG8; 2x2 with HDAWG4.  /dev.../awgs/0..3/
-    CHAN_GROUP_2x4 = 1  # 2x4 with HDAWG8; 1x4 with HDAWG4.  /dev.../awgs/0 & 2/
-    CHAN_GROUP_1x8 = 2  # 1x8 with HDAWG8.                   /dev.../awgs/0/
-
-    def group_size(self) -> int:
-        return {
-            HDAWGChannelGrouping.CHAN_GROUP_4x2: 2,
-            HDAWGChannelGrouping.CHAN_GROUP_2x4: 4,
-            HDAWGChannelGrouping.CHAN_GROUP_1x8: 8,
-            HDAWGChannelGrouping.MDS: None
-        }[self]
-
-
-class HDAWGVoltageRange(Enum):
-    """All available voltage ranges for the HDAWG wave outputs. Define maximum output voltage."""
-    RNG_5V = 5
-    RNG_4V = 4
-    RNG_3V = 3
-    RNG_2V = 2
-    RNG_1V = 1
-    RNG_800mV = 0.8
-    RNG_600mV = 0.6
-    RNG_400mV = 0.4
-    RNG_200mV = 0.2
-
-
-class HDAWGModulationMode(Enum):
-    """Modulation mode of waveform generator."""
-    OFF = 0  # AWG output goes directly to signal output.
-    SINE_1 = 1  # AWG output multiplied with sine generator signal 0.
-    SINE_2 = 2  # AWG output multiplied with sine generator signal 1.
-    FG_1 = 3  # AWG output multiplied with function generator signal 0. Requires FG option.
-    FG_2 = 4  # AWG output multiplied with function generator signal 1. Requires FG option.
-    ADVANCED = 5  # AWG output modulates corresponding sines from modulation carriers.
-
-
-@traced
-class HDAWGChannelGroup(AWG):
-    MIN_WAVEFORM_LEN = 192
-    WAVEFORM_LEN_QUANTUM = 16
-
-    def __init__(self,
-                 identifier: str,
-                 timeout: float) -> None:
-        super().__init__(identifier)
-        self.timeout = timeout
-
-        self._awg_module = None
-        self._program_manager = HDAWGProgramManager()
-        self._elf_manager = None
-        self._required_seqc_source = self._program_manager.to_seqc_program()
-        self._uploaded_seqc_source = None
-        self._current_program = None  # Currently armed program.
-        self._upload_generator = ()
-
-        self._master_device = None
-
-    def _initialize_awg_module(self):
-        """Only run once"""
-        if self._awg_module:
-            self._awg_module.clear()
-        self._awg_module = self.master_device.api_session.awgModule()
-        self._awg_module.set('awgModule/device', self.master_device.serial)
-        self._awg_module.set('awgModule/index', self.awg_group_index)
-        self._awg_module.execute()
-        self._elf_manager = ELFManager(self._awg_module)
-        self._upload_generator = ()
-
-    @property
-    def master_device(self) -> HDAWGRepresentation:
-        """Reference to HDAWG representation."""
-        if self._master_device is None:
-            raise HDAWGValueError('Channel group is currently not connected')
-        return self._master_device
-
-    @property
-    def awg_module(self) -> zhinst_core.AwgModule:
-        """Each AWG channel group has its own awg module to manage program compilation and upload."""
-        if self._awg_module is None:
-            raise HDAWGValueError('Channel group is not connected and was never initialized')
-        return self._awg_module
-
-    @property
-    @abstractmethod
-    def awg_group_index(self) -> int:
-        raise NotImplementedError()
-
-    @property
-    def num_markers(self) -> int:
-        """Number of marker channels"""
-        return 2 * self.num_channels
-
-    def upload(self, name: str,
-               program: Loop,
-               channels: Tuple[Optional[ChannelID], ...],
-               markers: Tuple[Optional[ChannelID], ...],
-               voltage_transformation: Tuple[Callable, ...],
-               force: bool = False) -> None:
-        """Upload a program to the AWG.
-
-        Physically uploads all waveforms required by the program - excluding those already present -
-        to the device and sets up playback sequences accordingly.
-        This method should be cheap for program already on the device and can therefore be used
-        for syncing. Programs that are uploaded should be fast(~1 sec) to arm.
-
-        Args:
-            name: A name for the program on the AWG.
-            program: The program (a sequence of instructions) to upload.
-            channels: Tuple of length num_channels that ChannelIDs of  in the program to use. Position in the list
-            corresponds to the AWG channel
-            markers: List of channels in the program to use. Position in the List in the list corresponds to
-            the AWG channel
-            voltage_transformation: transformations applied to the waveforms extracted rom the program. Position
-            in the list corresponds to the AWG channel
-            force: If a different sequence is already present with the same name, it is
-                overwritten if force is set to True. (default = False)
-
-        Known programs are handled in host memory most of the time. Only when uploading the
-        device memory is touched at all.
-
-        Returning from setting user register in seqc can take from 50ms to 60 ms. Fluctuates heavily. Not a good way to
-        have deterministic behaviour "setUserReg(PROG_SEL, PROG_IDLE);".
-        """
-        if len(channels) != self.num_channels:
-            raise HDAWGValueError('Channel ID not specified')
-        if len(markers) != self.num_markers:
-            raise HDAWGValueError('Markers not specified')
-        if len(voltage_transformation) != self.num_channels:
-            raise HDAWGValueError('Wrong number of voltage transformations')
-
-        if name in self.programs and not force:
-            raise HDAWGValueError('{} is already known on {}'.format(name, self.identifier))
-
-        # Go to qupulse nanoseconds time base.
-        q_sample_rate = self.sample_rate / 10**9
-
-        # Adjust program to fit criteria.
-        make_compatible(program,
-                        minimal_waveform_length=self.MIN_WAVEFORM_LEN,
-                        waveform_quantum=self.WAVEFORM_LEN_QUANTUM,
-                        sample_rate=q_sample_rate)
-
-        if self._amplitude_offset_handling == AWGAmplitudeOffsetHandling.IGNORE_OFFSET:
-            voltage_offsets = (0.,) * self.num_channels
-        elif self._amplitude_offset_handling == AWGAmplitudeOffsetHandling.CONSIDER_OFFSET:
-            voltage_offsets = self.offsets()
-        else:
-            raise ValueError('{} is invalid as AWGAmplitudeOffsetHandling'.format(self._amplitude_offset_handling))
-
-        amplitudes = self.amplitudes()
-
-        if name in self._program_manager.programs:
-            self._program_manager.remove(name)
-
-        self._program_manager.add_program(name,
-                                          program,
-                                          channels=channels,
-                                          markers=markers,
-                                          voltage_transformations=voltage_transformation,
-                                          sample_rate=q_sample_rate,
-                                          amplitudes=amplitudes,
-                                          offsets=voltage_offsets)
-
-        self._required_seqc_source = self._program_manager.to_seqc_program()
-        self._program_manager.waveform_memory.sync_to_file_system(self.master_device.waveform_file_system)
-
-        # start compiling the source (non-blocking)
-        self._start_compile_and_upload()
-
-    def _start_compile_and_upload(self):
-        self._uploaded_seqc_source = None
-        self._upload_generator = self._elf_manager.compile_and_upload(self._required_seqc_source)
-
-    def _wait_for_compile_and_upload(self):
-        for state in self._upload_generator:
-            logger.debug("wait_for_compile_and_upload: %r", state)
-            time.sleep(.1)
-        self._uploaded_seqc_source = self._required_seqc_source
-        logger.debug("AWG %d: wait_for_compile_and_upload has finished", self.awg_group_index)
-
-    def was_current_program_finished(self) -> bool:
-        """Return true if the current program has finished at least once"""
-        playback_finished_mask = int(HDAWGProgramManager.Constants.PLAYBACK_FINISHED_MASK, 2)
-        return bool(self.user_register(HDAWGProgramManager.Constants.PROG_SEL_REGISTER) & playback_finished_mask)
-
-    def set_volatile_parameters(self, program_name: str, parameters: Mapping[str, numbers.Real]):
-        """Set the values of parameters which were marked as volatile on program creation."""
-        new_register_values = self._program_manager.get_register_values_to_update_volatile_parameters(program_name,
-                                                                                                      parameters)
-        if self._current_program == program_name:
-            for register, value in new_register_values.items():
-                self.user_register(register, value)
-
-    def remove(self, name: str) -> None:
-        """Remove a program from the AWG.
-
-        Also discards all waveforms referenced only by the program identified by name.
-
-        Args:
-            name: The name of the program to remove.
-        """
-        self._program_manager.remove(name)
-        self._required_seqc_source = self._program_manager.to_seqc_program()
-
-    def clear(self) -> None:
-        """Removes all programs and waveforms from the AWG.
-
-        Caution: This affects all programs and waveforms on the AWG, not only those uploaded using qupulse!
-        """
-        self._program_manager.clear()
-        self._current_program = None
-        self._required_seqc_source = self._program_manager.to_seqc_program()
-        self._start_compile_and_upload()
-        self.arm(None)
-
-    def arm(self, name: Optional[str]) -> None:
-        """Load the program 'name' and arm the device for running it. If name is None the awg will "dearm" its current
-        program.
-
-        Currently hardware triggering is not implemented. The HDAWGProgramManager needs to emit code that calls
-        `waitDigTrigger` to do that.
-        """
-        if self.num_channels > 8:
-            if name is None:
-                self._required_seqc_source = ""
-            else:
-                self._required_seqc_source = self._program_manager.to_seqc_program(name)
-            self._start_compile_and_upload()
-
-        if self._required_seqc_source != self._uploaded_seqc_source:
-            self._wait_for_compile_and_upload()
-
-        self.user_register(self._program_manager.Constants.TRIGGER_REGISTER, 0)
-
-        if name is None:
-            self.user_register(self._program_manager.Constants.PROG_SEL_REGISTER,
-                               self._program_manager.Constants.PROG_SEL_NONE)
-            self._current_program = None
-        else:
-            if name not in self.programs:
-                raise HDAWGValueError('{} is unknown on {}'.format(name, self.identifier))
-            self._current_program = name
-
-            # set the registers of initial repetition counts
-            for register, value in self._program_manager.get_register_values(name).items():
-                assert register not in (self._program_manager.Constants.PROG_SEL_REGISTER,
-                                        self._program_manager.Constants.TRIGGER_REGISTER)
-                self.user_register(register, value)
-
-            self.user_register(self._program_manager.Constants.PROG_SEL_REGISTER,
-                               self._program_manager.name_to_index(name) | int(self._program_manager.Constants.NO_RESET_MASK, 2))
-
-        # this was a workaround for problems in the past and I totally forgot why it was here
-        # for ch_pair in self.master.channel_tuples:
-        #    ch_pair._wait_for_compile_and_upload()
-        self.enable(True)
-
-    def run_current_program(self) -> None:
-        """Run armed program."""
-        if self._current_program is not None:
-            if self._current_program not in self.programs:
-                raise HDAWGValueError('{} is unknown on {}'.format(self._current_program, self.identifier))
-            if not self.enable():
-                self.enable(True)
-            self.user_register(self._program_manager.Constants.TRIGGER_REGISTER,
-                               int(self._program_manager.Constants.TRIGGER_RESET_MASK, 2))
-        else:
-            raise HDAWGRuntimeError('No program active')
-
-    @property
-    def programs(self) -> Set[str]:
-        """The set of program names that can currently be executed on the hardware AWG."""
-        return set(self._program_manager.programs.keys())
-
-    @property
-    def sample_rate(self) -> TimeType:
-        """The default sample rate of the AWG channel group."""
-        node_path = '/{}/awgs/{}/time'.format(self.master_device.serial, self.awg_group_index)
-        sample_rate_num = self.master_device.api_session.getInt(node_path)
-        node_path = '/{}/system/clocks/sampleclock/freq'.format(self.master_device.serial)
-        sample_clock = self.master_device.api_session.getDouble(node_path)
-
-        """Calculate exact rational number based on (sample_clock Sa/s) / 2^sample_rate_num. Otherwise numerical
-        imprecision will give rise to errors for very long pulses. fractions.Fraction does not accept floating point
-        numerator, which sample_clock could potentially be."""
-        return time_from_float(sample_clock) / 2 ** sample_rate_num
-
-    def connect_group(self, hdawg_device: HDAWGRepresentation):
-        self.disconnect_group()
-        self._master_device = weakref.proxy(hdawg_device)
-        self._initialize_awg_module()
-        # Seems creating AWG module sets SINGLE (single execution mode of sequence) to 0 per default.
-        self.master_device.api_session.setInt(f'/{self.master_device.serial}/awgs/0/single', 1)
-
-    def disconnect_group(self):
-        """Disconnect this group from device so groups of another size can be used"""
-        if self._awg_module:
-            self.awg_module.clear()
-        self._master_device = None
-        self._elf_manager = None
-        self._upload_generator = ()
-
-    def is_connected(self) -> bool:
-        return self._master_device is not None
-
-    def user_register(self, reg: UserRegister, value: int = None) -> int:
-        """Query user registers (1-16) and optionally set it.
-
-        Args:
-            reg: User register. If it is an int, a warning is raised and it is interpreted as a one based index
-            value: Value to set
-
-        Returns:
-            User Register value after setting it
-        """
-        if isinstance(reg, int):
-            warnings.warn("User register is not a UserRegister instance. It is interpreted as one based index.")
-            reg = UserRegister(one_based_value=reg)
-
-        if reg.to_web_interface() not in range(1, 17):
-            raise HDAWGValueError(f'{reg:!r} not a valid (1-16) register.')
-
-        node_path = '/{}/awgs/{:d}/userregs/{:labone}'.format(self.master_device.serial, self.awg_group_index, reg)
-        if value is not None:
-            self.master_device.api_session.setInt(node_path, value)
-            # hackedy
-            for mds_serial in getattr(self, '_mds_devices', [])[1:]:
-                self.master_device.api_session.setInt(node_path.replace(self.master_device.serial, mds_serial), value)
-            self.master_device.api_session.sync()  # Global sync: Ensure settings have taken effect on the device.
-        return self.master_device.api_session.getInt(node_path)
-
-
-@traced
-class MDSChannelGroup(HDAWGChannelGroup):
-    def __init__(self,
-                 identifier: str,
-                 timeout: float) -> None:
-        super().__init__(identifier, timeout)
-
-        self._master_device = None
-        self._mds_devices = None
-
-    @property
-    def num_channels(self) -> int:
-        """Number of channels"""
-        return len(self._mds_devices) * 8
-
-    @property
-    def awg_group_index(self):
-        return 0
-
-    def disconnect_group(self):
-        super().disconnect_group()
-        self._mds_devices = None
-
-    def connect_group(self, hdawg_device: HDAWGRepresentation):
-        mds_group = hdawg_device._get_mds_group_idx()
-        if mds_group is None:
-            raise HDAWGException("AWG not in any MDS group", hdawg_device)
-        mds_devices = hdawg_device.api_session.getString(f'/ZI/MDS/GROUPS/{mds_group}/DEVICES').split(',')
-        if hdawg_device.serial != mds_devices[0]:
-            raise HDAWGException("Only the master device can connect to the HDAWG MDS channel group.")
-        super().connect_group(hdawg_device)
-        self._mds_devices = mds_devices
-
-    def enable(self, status: bool = None) -> bool:
-        """Start the AWG sequencer."""
-        # There is also 'awgModule/awg/enable', which seems to have the same functionality.
-        node_path = '/{}/awgs/{:d}/enable'.format(self.master_device.serial, 0)
-        if status is not None:
-            self.awg_module.set('awg/enable', int(status))
-        else:
-            status = self.awg_module.get('awg/module')
-
-        #return bool(status)
-        """
-        if status is not None:
-            self.master_device.api_session.setInt(node_path, int(status))
-            for mds_device in self._mds_devices[1:]:
-                self.master_device.api_session.setInt(node_path.replace(self._mds_devices[0], mds_device), int(status))
-            self.master_device.api_session.sync()  # Global sync: Ensure settings have taken effect on the device.
-            """
-        return bool(self.master_device.api_session.getInt(node_path))
-
-    def amplitudes(self) -> Tuple[float, ...]:
-        """Query AWG channel amplitude value (not peak to peak).
-
-        From manual:
-        The final signal amplitude is given by the product of the full scale
-        output range of 1 V[in this example], the dimensionless amplitude
-        scaling factor 1.0, and the actual dimensionless signal amplitude
-        stored in the waveform memory."""
-        amplitudes = []
-
-        api_session = self.master_device.api_session
-        for mds_device in self._mds_devices:
-            amplitude_scales = _amplitude_scales(api_session, mds_device)
-            ranges = [_sigout_range(api_session, mds_device, ch) for ch in range(1, 9)]
-            amplitudes.extend(zi_amplitude * zi_range / 2 for zi_amplitude, zi_range in zip(amplitude_scales, ranges))
-        return tuple(amplitudes)
-
-    def offsets(self) -> Tuple[float, ...]:
-        offsets = []
-        api_session = self.master_device.api_session
-        for mds_device in self._mds_devices:
-            offsets.extend(_sigout_offset(api_session, mds_device, ch) for ch in range(1, 9))
-        return tuple(offsets)
-
-
-class SingleDeviceChannelGroup(HDAWGChannelGroup):
-    def __init__(self,
-                 group_idx: int,
-                 group_size: int,
-                 identifier: str,
-                 timeout: float) -> None:
-        super().__init__(identifier, timeout)
-        self._device = None
-
-        assert group_idx in range(4)
-        assert group_size in (2, 4, 8)
-
-        self._group_idx = group_idx
-        self._group_size = group_size
-
-    @property
-    def num_channels(self) -> int:
-        """Number of channels"""
-        return self._group_size
-
-    def _channels(self, index_start=1) -> Tuple[int, ...]:
-        """1 indexed channel"""
-        offset = index_start + self._group_size * self._group_idx
-        return tuple(ch + offset for ch in range(self.num_channels))
-
-    @property
-    def awg_group_index(self) -> int:
-        """AWG node group index assuming 4x2 channel grouping. Then 0...3 will give appropriate index of group."""
-        return self._group_idx
-
-    @property
-    def user_directory(self) -> str:
-        """LabOne user directory with subdirectories: "awg/src" (seqc sourcefiles), "awg/elf" (compiled AWG binaries),
-        "awag/waves" (user defined csv waveforms)."""
-        return self.awg_module.getString('awgModule/directory')
-
-    def enable(self, status: bool = None) -> bool:
-        """Start the AWG sequencer."""
-        # There is also 'awgModule/awg/enable', which seems to have the same functionality.
-        node_path = '/{}/awgs/{:d}/enable'.format(self.master_device.serial, self.awg_group_index)
-        if status is not None:
-            self.master_device.api_session.setInt(node_path, int(status))
-            self.master_device.api_session.sync()  # Global sync: Ensure settings have taken effect on the device.
-        return bool(self.master_device.api_session.getInt(node_path))
-
-    def amplitudes(self) -> Tuple[float, ...]:
-        """Query AWG channel amplitude value (not peak to peak).
-
-        From manual:
-        The final signal amplitude is given by the product of the full scale
-        output range of 1 V[in this example], the dimensionless amplitude
-        scaling factor 1.0, and the actual dimensionless signal amplitude
-        stored in the waveform memory."""
-        amplitudes = []
-
-        for ch, zi_amplitude in zip(self._channels(), _amplitude_scales(self.master_device.api_session, self.master_device.serial)):
-            zi_range = self.master_device.range(ch)
-            amplitudes.append(zi_amplitude * zi_range / 2)
-        return tuple(amplitudes)
-
-    def offsets(self) -> Tuple[float, ...]:
-        return tuple(map(self.master_device.offset, self._channels()))
-
-
-class ELFManager:
-    class AWGModule:
-        def __init__(self, awg_module: zhinst_core.AwgModule):
-            """Provide an easily mockable interface to the zhinst AwgModule object"""
-            self._module = awg_module
-
-        @property
-        def src_dir(self) -> pathlib.Path:
-            return pathlib.Path(self._module.getString('directory'), 'awg', 'src')
-
-        @property
-        def elf_dir(self) -> pathlib.Path:
-            return pathlib.Path(self._module.getString('directory'), 'awg', 'elf')
-
-        @property
-        def compiler_start(self) -> bool:
-            """True if the compiler is running"""
-            return self._module.getInt('compiler/start') == 1
-
-        @compiler_start.setter
-        def compiler_start(self, value: bool):
-            """Set true to start the compiler"""
-            self._module.set('compiler/start', value)
-
-        @property
-        def compiler_status(self) -> Tuple[int, str]:
-            return self._module.getInt('compiler/status'), self._module.getString('compiler/statusstring')
-
-        @property
-        def compiler_source_file(self) -> str:
-            return self._module.getString('compiler/sourcefile')
-
-        @compiler_source_file.setter
-        def compiler_source_file(self, source_file: str):
-            self._module.set('compiler/sourcefile', source_file)
-
-        @property
-        def compiler_upload(self) -> bool:
-            """auto upload after compiling"""
-            return self._module.getInt('compiler/upload') == 1
-
-        @compiler_upload.setter
-        def compiler_upload(self, value: bool):
-            self._module.set('compiler/upload', value)
-
-        @property
-        def elf_file(self) -> str:
-            return self._module.getString('elf/file')
-
-        @elf_file.setter
-        def elf_file(self, elf_file: str):
-            self._module.set('elf/file', elf_file)
-
-        @property
-        def elf_upload(self) -> bool:
-            return bool(self._module.getInt('elf/upload'))
-
-        @elf_upload.setter
-        def elf_upload(self, value: bool):
-            self._module.set('elf/upload', value)
-
-        @property
-        def elf_status(self) -> Tuple[int, float]:
-            return self._module.getInt('elf/status'), self._module.getDouble('progress')
-
-        @property
-        def index(self) -> int:
-            return self._module.getInt('index')
-
-    def __init__(self, awg_module: zhinst_core.AwgModule):
-        """This class organizes compiling and uploading of compiled programs. The source code file is named based on the
-        code hash to cache compilation results. This requires that the waveform names are unique.
-
-        The compilation and upload itself are done asynchronously by zhinst.core. To avoid spawning a useless
-        thread for updating the status the method :py:meth:`~ELFManager.compile_and_upload` returns a generator which
-        talks to the undelying library when needed."""
-        self.awg_module = self.AWGModule(awg_module)
-
-        # automatically upload after successful compilation
-        self.awg_module.compiler_upload = True
-
-        self._compile_job = None  # type: Optional[Union[str, Tuple[str, int, str]]]
-        self._upload_job = None  # type: Optional[Union[Tuple[str, float], Tuple[str, int]]]
-
-    def clear(self):
-        """Deletes all files with a SHA512 hash name"""
-        src_regex = re.compile(r'[a-z0-9]{128}\.seqc')
-        elf_regex = re.compile(r'[a-z0-9]{128}\.elf')
-
-        for p in self.awg_module.src_dir.iterdir():
-            if src_regex.match(p.name):
-                p.unlink()
-
-        for p in self.awg_module.elf_dir.iterdir():
-            if elf_regex.match(p.name):
-                p.unlink()
-
-    @staticmethod
-    def _source_hash(source_string: str) -> str:
-        """Calulate the SHA512 hash of the given source.
-
-        Args:
-            source_string: seqc source code
-
-        Returns:
-            hex representation of SHA512 `source_string` hash
-        """
-        # use utf-16 because str is UTF16 on most relevant machines (Windows)
-        return hashlib.sha512(bytes(source_string, 'utf-16')).hexdigest()
-
-    def _update_compile_job_status(self):
-        """Store current compile status in self._compile_job."""
-        compiler_start = self.awg_module.compiler_start
-        if self._compile_job is None:
-            assert compiler_start == 0
-
-        elif isinstance(self._compile_job, str):
-            if compiler_start:
-                # compilation is running
-                pass
-
-            else:
-                compiler_status, status_string = self.awg_module.compiler_status
-                assert compiler_status in (-1, 0, 1, 2)
-                if compiler_status == -1:
-                    raise RuntimeError('Compile job is set but no compilation is running', status_string)
-                elif compiler_status == 2:
-                    logger.warning("AWG %d: Compilation finished with warning: %s", self.awg_module.index, status_string)
-                self._compile_job = (self._compile_job, compiler_status, status_string)
-
-    def _start_compile_job(self, source_file):
-        logger.debug("Starting compilation of %r", source_file)
-        self._update_compile_job_status()
-        assert not isinstance(self._compile_job, str)
-        self.awg_module.compiler_source_file = source_file
-        self.awg_module.compiler_start = True
-        self._compile_job = source_file
-        logger.debug("AWG %d: Compilation of %r started", self.awg_module.index, source_file)
-
-    def _compile(self, source_file) -> Generator[str, str, None]:
-        self._start_compile_job(source_file)
-
-        while True:
-            self._update_compile_job_status()
-            if not isinstance(self._compile_job, str):
-                # finished compiling
-                logger.debug("AWG %d: Compilation of %r finished", self.awg_module.index, source_file)
-                break
-            cmd = yield 'compiling'
-            if cmd is None:
-                logger.debug('No command received during compiling')
-            elif cmd == 'abort':
-                raise NotImplementedError('clean abort not implemented')
-            else:
-                raise HDAWGValueError('Unknown command', cmd)
-
-        _, status_int, status_str = self._compile_job
-        if status_int == 1:
-            raise HDAWGRuntimeError('Compilation failed', status_str)
-        logger.info("AWG %d: Compilation of %r successful", self.awg_module.index, source_file)
-
-    def _start_elf_upload(self, elf_file):
-        logger.debug("Uploading %r", elf_file)
-        current_elf = self.awg_module.elf_file
-        if current_elf != elf_file:
-            logger.info("AWG %d: Overwriting elf file", self.awg_module.index)
-            self.awg_module.elf_file = elf_file
-            self.awg_module.elf_upload = True
-        self._upload_job = (elf_file, None)
-        time.sleep(.001)
-
-    def _update_upload_job_status(self):
-        elf_upload = self.awg_module.elf_upload
-        if self._upload_job is None:
-            assert not elf_upload
-            return
-
-        elf_file, old_status = self._upload_job
-        assert self.awg_module.elf_file == elf_file
-
-        if isinstance(old_status, float) or old_status is None:
-            status_int, progress = self.awg_module.elf_status
-            if status_int == 2:
-                # in progress
-                assert elf_upload == 1
-                self._upload_job = elf_file, progress
-            else:
-                # fetch new value here
-                self._upload_job = elf_file, status_int
-
-        else:
-            logger.debug('AWG %d: _update_upload_job_status called on finished upload', self.awg_module.index)
-            assert elf_upload == 0
-
-    def _upload(self, elf_file) -> Generator[str, str, None]:
-        if self.awg_module.compiler_upload:
-            pass
-        else:
-            self._start_elf_upload(elf_file)
-
-        while True:
-            self._update_upload_job_status()
-            _, status = self._upload_job
-            if isinstance(status, int):
-                assert status in (-1, 0, 1)
-                if status == 1:
-                    raise RuntimeError('ELF upload failed')
-                else:
-                    break
-            else:
-                progress = status
-                logger.debug('AWG %d: Upload progress is %d%%', self.awg_module.index, progress*100)
-
-                cmd = yield 'uploading @ %d%%' % (100*progress)
-                if cmd is None:
-                    logger.debug("No command received during upload")
-                if cmd == 'abort':
-                    # TODO: check if this stops the upload
-                    self.awg_module.elf_upload = False
-                    raise NotImplementedError('Abort upload not cleanly implemented')
-                else:
-                    raise HDAWGValueError('Unknown command', cmd)
-
-        # enable auto upload on compilation again
-        # TODO: research whether this is necessary
-        # self.awg_module.elf_file = ''
-
-    def compile_and_upload(self, source_string: str) -> Generator[str, str, None]:
-        """The source code is saved to a file determined by the source hash, compiled and uploaded to the instrument.
-        The function returns a generator that yields the current state of the progress. The generator is empty iff the
-        upload is complete. An exception is raised if there is an error.
-
-        To abort send 'abort' to the generator.
-
-        Example:
-            >>> my_source = 'playWave("my_wave");'
-            >>> for state in elf_manager.compile_and_upload(my_source):
-            ...     print('Current state:', state)
-            ...     time.sleep(1)
-
-        Args:
-            source_string: Source code to compile
-
-        Returns:
-            Generator object that needs to be consumed
-        """
-        self._update_compile_job_status()
-        if isinstance(self._compile_job, str):
-            raise NotImplementedError('cannot upload: compilation in progress')
-
-        source_hash = self._source_hash(source_string)
-
-        seqc_file_name = '%s.seqc' % source_hash
-        elf_file_name = '%s.elf' % source_hash
-
-        full_source_name = self.awg_module.src_dir.joinpath(seqc_file_name)
-        full_elf_name = self.awg_module.elf_dir.joinpath(elf_file_name)
-
-        if not full_source_name.exists():
-            full_source_name.write_text(source_string, 'utf-8')
-
-        # we assume same source == same program here
-        if not full_elf_name.exists():
-            yield from self._compile(seqc_file_name)
-        else:
-            # set this so the web interface shows the correct source
-            # self.awg_module.compiler_source_file = seqc_file_name
-            logger.info('Already compiled. ELF: %r', elf_file_name)
-
-        yield from self._upload(elf_file_name)
-
-
-class HDAWGException(Exception):
-    """Base exception class for HDAWG errors."""
-    pass
-
-
-class HDAWGValueError(HDAWGException, ValueError):
-    pass
-
-
-class HDAWGTypeError(HDAWGException, TypeError):
-    pass
-
-
-class HDAWGRuntimeError(HDAWGException, RuntimeError):
-    pass
-
-
-class HDAWGIOError(HDAWGException, IOError):
-    pass
-
-
-class HDAWGTimeoutError(HDAWGException, TimeoutError):
-    pass
-
-
-class HDAWGCompilationException(HDAWGException):
-    def __init__(self, msg):
-        self.msg = msg
-
-    def __str__(self) -> str:
-        return "Compilation failed: {}".format(self.msg)
-
-
-class HDAWGUploadException(HDAWGException):
-    def __str__(self) -> str:
-        return "Upload to the instrument failed."
-
-
-def get_group_for_channels(hdawg: HDAWGRepresentation, channels: Set[int]) -> HDAWGChannelGroup:
-    channels = set(channels)
-    assert not channels - set(range(8)), "Channels must be in 0..=7"
+import logging
 
-    channel_range = range(min(channels) // 2 * 2, (max(channels) + 2) // 2 * 2)
-    if len(channel_range) > 4 or len(channel_range) == 4 and channel_range.start == 2:
-        c = (HDAWGChannelGrouping.CHAN_GROUP_1x8, 0)
-    elif len(channel_range) == 4:
-        assert channel_range.start in (0, 4)
-        c = (HDAWGChannelGrouping.CHAN_GROUP_2x4, channel_range.start // 4)
-    else:
-        assert len(channel_range) == 2
-        c = (HDAWGChannelGrouping.CHAN_GROUP_4x2, channel_range.start // 2)
+from typing import Set
 
-    hdawg.channel_grouping = c[0]
-    return hdawg.channel_tuples[c[1]]
+if sys.version_info.minor > 8:
+    try:
+        from qupulse_hdawg.zihdawg import *
+    except ImportError:
+        print("Install the qupulse_hdawg package to use HDAWG with this python version.")
+        raise
+else:
+    try:
+        from qupulse_hdawg_legacy.zihdawg import *
+    except ImportError:
+        print("Install the qupulse_hdawg_legacy package to use HDAWG with this python version.")
+        raise
 
 
 def example_upload(hdawg_kwargs: dict, channels: Set[int], markers: Set[Tuple[int, int]]):  # pragma: no cover
@@ -1227,5 +115,4 @@ def example_upload(hdawg_kwargs: dict, channels: Set[int], markers: Set[Tuple[in
     markers = [(m // 2, m % 2) for m in parsed.pop('markers')]
 
     logging.basicConfig(stream=sys.stdout)
-    logger.setLevel(logging.DEBUG)
     example_upload(hdawg_kwargs=parsed, channels=channels, markers=markers)
diff --git a/qupulse/hardware/dacs/alazar.py b/qupulse/hardware/dacs/alazar.py
index 7398e2115..c694c4be6 100644
--- a/qupulse/hardware/dacs/alazar.py
+++ b/qupulse/hardware/dacs/alazar.py
@@ -16,7 +16,7 @@
 from qupulse.utils.types import TimeType
 from qupulse.hardware.dacs.dac_base import DAC
 from qupulse.hardware.util import traced
-from qupulse.utils.performance import time_windows_to_samples
+from qupulse.utils.performance import time_windows_to_samples, shrink_overlapping_windows
 
 logger = logging.getLogger(__name__)
 
@@ -283,8 +283,7 @@ def _make_mask(self, mask_id: str, begins, lengths) -> Mask:
         if mask_type not in ('auto', 'cross_buffer', None):
             warnings.warn("Currently only CrossBufferMask is implemented.")
 
-        if np.any(begins[:-1]+lengths[:-1] > begins[1:]):
-            raise ValueError('Found overlapping windows in begins')
+        begins, lengths = shrink_overlapping_windows(begins, lengths)
 
         mask = CrossBufferMask()
         mask.identifier = mask_id
diff --git a/qupulse/hardware/dacs/dummy.py b/qupulse/hardware/dacs/dummy.py
new file mode 100644
index 000000000..ba0cf6f51
--- /dev/null
+++ b/qupulse/hardware/dacs/dummy.py
@@ -0,0 +1,46 @@
+from typing import Tuple, Set, Dict
+from collections import deque
+
+from qupulse.hardware.dacs.dac_base import DAC
+
+class DummyDAC(DAC):
+    """Dummy DAC for automated testing, debugging and usage in examples. """
+
+    def __init__(self):
+        self._measurement_windows = dict()
+        self._operations = dict()
+        self.measured_data = deque([])
+        self._meas_masks = {}
+        self._armed_program = None
+
+    @property
+    def armed_program(self):
+        return self._armed_program
+
+    def register_measurement_windows(self, program_name: str, windows: Dict[str, Tuple['numpy.ndarray',
+                                                                                       'numpy.ndarray']]):
+        self._measurement_windows[program_name] = windows
+
+    def register_operations(self, program_name: str, operations):
+        self._operations[program_name] = operations
+
+    def arm_program(self, program_name: str):
+        self._armed_program = program_name
+
+    def delete_program(self, program_name):
+        if program_name in self._operations:
+            self._operations.pop(program_name)
+        if program_name in self._measurement_windows:
+            self._measurement_windows.pop(program_name)
+
+    def clear(self) -> None:
+        self._measurement_windows = dict()
+        self._operations = dict()
+        self._armed_program = None
+
+    def measure_program(self, channels):
+        return self.measured_data.pop()
+
+    def set_measurement_mask(self, program_name, mask_name, begins, lengths) -> Tuple['numpy.ndarray', 'numpy.ndarray']:
+        self._meas_masks.setdefault(program_name, {})[mask_name] = (begins, lengths)
+        return begins, lengths
diff --git a/qupulse/hardware/feature_awg/channel_tuple_wrapper.py b/qupulse/hardware/feature_awg/channel_tuple_wrapper.py
index 10212153b..a840ce9c9 100644
--- a/qupulse/hardware/feature_awg/channel_tuple_wrapper.py
+++ b/qupulse/hardware/feature_awg/channel_tuple_wrapper.py
@@ -1,7 +1,7 @@
 from typing import Tuple, Optional, Callable, Set
 
 from qupulse import ChannelID
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 from qupulse.hardware.feature_awg.base import AWGChannelTuple
 from qupulse.hardware.feature_awg.features import ProgramManagement, VolatileParameters
 from qupulse.hardware.awgs.base import AWG
diff --git a/qupulse/hardware/feature_awg/features.py b/qupulse/hardware/feature_awg/features.py
index 1b82189cc..29439f70a 100644
--- a/qupulse/hardware/feature_awg/features.py
+++ b/qupulse/hardware/feature_awg/features.py
@@ -3,7 +3,7 @@
 from numbers import Real
 from enum import Enum
 
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 from qupulse.hardware.feature_awg.base import AWGDeviceFeature, AWGChannelFeature, AWGChannelTupleFeature,\
     AWGChannelTuple
 from qupulse.utils.types import ChannelID
diff --git a/qupulse/hardware/feature_awg/tabor.py b/qupulse/hardware/feature_awg/tabor.py
index 4a08ab8b1..554939719 100644
--- a/qupulse/hardware/feature_awg/tabor.py
+++ b/qupulse/hardware/feature_awg/tabor.py
@@ -11,7 +11,7 @@
 import numpy as np
 
 from qupulse import ChannelID
-from qupulse._program._loop import Loop, make_compatible
+from qupulse.program.loop import Loop, make_compatible
 
 from qupulse.hardware.feature_awg.channel_tuple_wrapper import ChannelTupleAdapter
 from qupulse.hardware.feature_awg.features import ChannelSynchronization, AmplitudeOffsetHandling, VoltageRange, \
diff --git a/qupulse/hardware/setup.py b/qupulse/hardware/setup.py
index 646815742..e976a6743 100644
--- a/qupulse/hardware/setup.py
+++ b/qupulse/hardware/setup.py
@@ -5,14 +5,14 @@
 
 from qupulse.hardware.awgs.base import AWG
 from qupulse.hardware.dacs import DAC
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 
 from qupulse.utils.types import ChannelID
 
 import numpy as np
 
 
-__all__ = ['PlaybackChannel', 'MarkerChannel', 'HardwareSetup']
+__all__ = ['PlaybackChannel', 'MarkerChannel', 'MeasurementMask', 'HardwareSetup']
 
 
 class MeasurementMask:
@@ -92,7 +92,20 @@ def __init__(self):
 
     def register_program(self, name: str,
                          program: Loop,
-                         run_callback=lambda: None, update=False) -> None:
+                         run_callback=lambda: None,
+                         update: bool = False,
+                         measurements: Mapping[str, Tuple[np.ndarray, np.ndarray]] = None) -> None:
+        """Register a program under a given name at the hardware setup. The program will be uploaded to the
+        participating AWGs and DACs. The run callback is used for triggering the program after arming.
+
+        Args:
+            name: Name of the program.
+            program: Output of :py:meth:`~PulseTemplate.create_program`
+            run_callback: Used to trigger the program after arming
+            update: Must be set if the program is already known.
+            measurements: Will be used as measurements if provided. Otherwise, the measurements are extracted from the program.
+
+        """
         if not callable(run_callback):
             raise TypeError('The provided run_callback is not callable')
 
@@ -101,8 +114,11 @@ def register_program(self, name: str,
             raise KeyError('The following channels are unknown to the HardwareSetup: {}'.format(
                 channels - set(self._channel_map.keys())))
 
+        if measurements is None:
+            measurements = program.get_measurement_windows(drop=True)
+
         temp_measurement_windows = defaultdict(list)
-        for mw_name, begins_lengths in program.get_measurement_windows(drop=True).items():
+        for mw_name, begins_lengths in measurements.items():
             temp_measurement_windows[mw_name].append(begins_lengths)
 
         if set(temp_measurement_windows.keys()) - set(self._measurement_map.keys()):
diff --git a/qupulse/hardware/util.py b/qupulse/hardware/util.py
index 30a9aae34..2d656d529 100644
--- a/qupulse/hardware/util.py
+++ b/qupulse/hardware/util.py
@@ -10,7 +10,7 @@ def traced(obj):
         """Noop traced that is used if autologging package is not available"""
         return obj
 
-from qupulse._program.waveforms import Waveform
+from qupulse.program.waveforms import Waveform
 from qupulse.utils.types import TimeType
 from qupulse.utils import pairwise
 
@@ -22,7 +22,7 @@ def traced(obj):
     njit = lambda x: x
 
 try:
-    import zhinst
+    import zhinst.utils
 except ImportError:  # pragma: no cover
     zhinst = None
 
@@ -99,7 +99,7 @@ def voltage_to_uint16(voltage: np.ndarray, output_amplitude: float, output_offse
 def find_positions(data: Sequence, to_find: Sequence) -> np.ndarray:
     """Find indices of the first occurrence of the elements of to_find in data. Elements that are not in data result in
     -1"""
-    data_sorter = np.argsort(data)
+    data_sorter = np.argsort(data, kind='stable')
 
     pos_left = np.searchsorted(data, to_find, side='left', sorter=data_sorter)
     pos_right = np.searchsorted(data, to_find, side='right', sorter=data_sorter)
diff --git a/qupulse/plotting.py b/qupulse/plotting.py
index cfe8d8c1e..0a62451fa 100644
--- a/qupulse/plotting.py
+++ b/qupulse/plotting.py
@@ -16,11 +16,18 @@
 import itertools
 import functools
 
-from qupulse._program import waveforms
+try:
+    from matplotlib import colormaps
+    get_cmap = colormaps.get_cmap
+except (ImportError, AttributeError):  # pragma: no cover
+    # was deprecated in matplotlib 3.7, but we keep it around to allow this code to work with older versions
+    get_cmap = plt.get_cmap
+
+from qupulse.program import waveforms
 from qupulse.utils.types import ChannelID, MeasurementWindow, has_type_interface
 from qupulse.pulses.pulse_template import PulseTemplate
-from qupulse._program.waveforms import Waveform
-from qupulse._program._loop import Loop, to_waveform
+from qupulse.program.waveforms import Waveform
+from qupulse.program.loop import Loop, to_waveform
 
 
 __all__ = ["render", "plot", "PlottingNotPossibleException"]
@@ -115,9 +122,9 @@ def _render_loop(loop: Loop,
     return waveform, measurements
 
 
-def plot(pulse: PulseTemplate,
-         parameters: Dict[str, Real]=None,
-         sample_rate: Optional[Real]=10,
+def plot(pulse: Union[PulseTemplate, Loop],
+         parameters: Optional[Dict[str, Real]] = None,
+         sample_rate: Optional[Real] = 10,
          axes: Any=None,
          show: bool=True,
          plot_channels: Optional[Set[ChannelID]]=None,
@@ -155,14 +162,14 @@ def plot(pulse: PulseTemplate,
     """
     from matplotlib import pyplot as plt
 
-    channels = pulse.defined_channels
-
-    if parameters is None:
-        parameters = dict()
+    try:
+        program = pulse.create_program(parameters=parameters)
+    except AttributeError:
+        program = pulse
 
     if sample_rate is None:
         if time_slice is None:
-            duration = pulse.duration
+            duration = program.duration
         else:
             duration = time_slice[1]-time_slice[0]
         if duration == 0:
@@ -170,10 +177,6 @@ def plot(pulse: PulseTemplate,
         else:
             duration_per_sample = float(duration) / 1000
             sample_rate = 1 / duration_per_sample
-            
-    program = pulse.create_program(parameters=parameters,
-                                   channel_mapping={ch: ch for ch in channels},
-                                   measurement_mapping={w: w for w in pulse.measurement_names})
 
     if program is not None:
         times, voltages, measurements = render(program,
@@ -222,7 +225,7 @@ def plot(pulse: PulseTemplate,
             if name in plot_measurements:
                 measurement_dict.setdefault(name, []).append((begin, begin+length))
 
-        color_map = plt.cm.get_cmap('plasma')
+        color_map = get_cmap('plasma')
         meas_colors = {name: color_map(i/len(measurement_dict))
                        for i, name in enumerate(measurement_dict.keys())}
         for name, begin_end_list in measurement_dict.items():
@@ -250,6 +253,7 @@ def plot(pulse: PulseTemplate,
         with warnings.catch_warnings():
             # do not show warnings in jupyter notebook with matplotlib inline backend
             warnings.filterwarnings(action="ignore",message=".*which is a non-GUI backend, so cannot show the figure.*")
+            warnings.filterwarnings(action="ignore",message=".*is non-interactive, and thus cannot be shown.*")
             axes.get_figure().show()
     return axes.get_figure()
 
diff --git a/qupulse/program/__init__.py b/qupulse/program/__init__.py
new file mode 100644
index 000000000..611a96fcd
--- /dev/null
+++ b/qupulse/program/__init__.py
@@ -0,0 +1,171 @@
+import contextlib
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import Optional, Union, Sequence, ContextManager, Mapping, Tuple, Generic, TypeVar, Iterable, Dict
+from numbers import Real, Number
+
+import numpy as np
+
+from qupulse._program.waveforms import Waveform
+from qupulse.utils.types import MeasurementWindow, TimeType, FrozenMapping
+from qupulse._program.volatile import VolatileRepetitionCount
+from qupulse.parameter_scope import Scope
+from qupulse.expressions import sympy as sym_expr, Expression
+from qupulse.utils.sympy import _lambdify_modules
+
+from typing import Protocol, runtime_checkable
+
+
+NumVal = TypeVar('NumVal', bound=Real)
+
+
+@dataclass
+class SimpleExpression(Generic[NumVal]):
+    """This is a potential hardware evaluable expression of the form
+
+    C + C1*R1 + C2*R2 + ...
+    where R1, R2, ... are potential runtime parameters.
+
+    The main use case is the expression of for loop dependent variables where the Rs are loop indices. There the
+    expressions can be calculated via simple increments.
+    """
+
+    base: NumVal
+    offsets: Mapping[str, NumVal]
+
+    def __post_init__(self):
+        assert isinstance(self.offsets, Mapping)
+
+    def value(self, scope: Mapping[str, NumVal]) -> NumVal:
+        value = self.base
+        for name, factor in self.offsets:
+            value += scope[name] * factor
+        return value
+
+    def __add__(self, other):
+        if isinstance(other, (float, int, TimeType)):
+            return SimpleExpression(self.base + other, self.offsets)
+
+        if type(other) == type(self):
+            offsets = self.offsets.copy()
+            for name, value in other.offsets.items():
+                offsets[name] = value + offsets.get(name, 0)
+            return SimpleExpression(self.base + other.base, offsets)
+
+        return NotImplemented
+
+    def __radd__(self, other):
+        return self.__add__(other)
+
+    def __sub__(self, other):
+        return self.__add__(-other)
+
+    def __rsub__(self, other):
+        (-self).__add__(other)
+
+    def __neg__(self):
+        return SimpleExpression(-self.base, {name: -value for name, value in self.offsets.items()})
+
+    def __mul__(self, other: NumVal):
+        if isinstance(other, (float, int, TimeType)):
+            return SimpleExpression(self.base * other, {name: other * value for name, value in self.offsets.items()})
+
+        return NotImplemented
+
+    def __rmul__(self, other):
+        return self.__mul__(other)
+
+    def __truediv__(self, other):
+        inv = 1 / other
+        return self.__mul__(inv)
+
+    @property
+    def free_symbols(self):
+        return ()
+
+    def _sympy_(self):
+        return self
+
+    def replace(self, r, s):
+        return self
+
+    def evaluate_in_scope_(self, *args, **kwargs):
+        # TODO: remove. It is currently required to avoid nesting this class in an expression for the MappedScope
+        # We can maybe replace is with a HardwareScope or something along those lines
+        return self
+
+
+_lambdify_modules.append({'SimpleExpression': SimpleExpression})
+
+
+RepetitionCount = Union[int, VolatileRepetitionCount, SimpleExpression[int]]
+HardwareTime = Union[TimeType, SimpleExpression[TimeType]]
+HardwareVoltage = Union[float, SimpleExpression[float]]
+
+
+@runtime_checkable
+class Program(Protocol):
+    """This protocol is used to inspect and or manipulate programs"""
+
+    @property
+    def duration(self) -> TimeType:
+        raise NotImplementedError()
+
+
+class ProgramBuilder(Protocol):
+    """This protocol is used by PulseTemplate to build the program via the visitor pattern.
+
+    There is a default implementation which is the loop class.
+
+    Other hardware backends can use this protocol to implement easy translation of pulse templates."""
+
+    def inner_scope(self, scope: Scope) -> Scope:
+        """This function is necessary to inject program builder specific parameter implementations into the build
+        process."""
+
+    def hold_voltage(self, duration: HardwareTime, voltages: Mapping[str, HardwareVoltage]):
+        """Supports dynamic i.e. for loop generated offsets and duration"""
+
+    # further specialized commandos like play_harmoic might be added here
+
+    def play_arbitrary_waveform(self, waveform: Waveform):
+        """"""
+
+    def measure(self, measurements: Optional[Sequence[MeasurementWindow]]):
+        """Unconditionally add given measurements relative to the current position."""
+
+    def with_repetition(self, repetition_count: RepetitionCount,
+                        measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']:
+        """Measurements that are added to the new builder are dropped if the builder is empty upon exit"""
+
+    def with_sequence(self,
+                      measurements: Optional[Sequence[MeasurementWindow]] = None) -> ContextManager['ProgramBuilder']:
+        """
+
+        Measurements that are added in to the returned program builder are discarded if the sequence is empty on exit.
+
+        Args:
+            measurements: Measurements to attach to the potential child. Is not repeated with repetition_count.
+            repetition_count:
+        Returns:
+        """
+
+    def new_subprogram(self, global_transformation: 'Transformation' = None) -> ContextManager['ProgramBuilder']:
+        """Create a context managed program builder whose contents are translated into a single waveform upon exit if
+        it is not empty."""
+
+    def with_iteration(self, index_name: str, rng: range,
+                       measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']:
+        pass
+
+    def to_program(self) -> Optional[Program]:
+        """Further addition of new elements might fail after finalizing the program."""
+
+
+def default_program_builder() -> ProgramBuilder:
+    from qupulse.program.loop import LoopBuilder
+    return LoopBuilder()
+
+
+# TODO: hackedy, hackedy
+sym_expr.ALLOWED_NUMERIC_SCALAR_TYPES = sym_expr.ALLOWED_NUMERIC_SCALAR_TYPES + (SimpleExpression,)
diff --git a/qupulse/program/linspace.py b/qupulse/program/linspace.py
new file mode 100644
index 000000000..0d454c090
--- /dev/null
+++ b/qupulse/program/linspace.py
@@ -0,0 +1,410 @@
+import abc
+import contextlib
+import dataclasses
+import numpy as np
+from dataclasses import dataclass
+from typing import Mapping, Optional, Sequence, ContextManager, Iterable, Tuple, Union, Dict, List, Iterator
+
+from qupulse import ChannelID, MeasurementWindow
+from qupulse.parameter_scope import Scope, MappedScope, FrozenDict
+from qupulse.program import (ProgramBuilder, HardwareTime, HardwareVoltage, Waveform, RepetitionCount, TimeType,
+                             SimpleExpression)
+from qupulse.program.waveforms import MultiChannelWaveform
+
+# this resolution is used to unify increments
+# the increments themselves remain floats
+DEFAULT_INCREMENT_RESOLUTION: float = 1e-9
+
+
+@dataclass(frozen=True)
+class DepKey:
+    """The key that identifies how a certain set command depends on iteration indices. The factors are rounded with a
+    given resolution to be independent on rounding errors.
+
+    These objects allow backends which support it to track multiple amplitudes at once.
+    """
+    factors: Tuple[int, ...]
+
+    @classmethod
+    def from_voltages(cls, voltages: Sequence[float], resolution: float):
+        # remove trailing zeros
+        while voltages and voltages[-1] == 0:
+            voltages = voltages[:-1]
+        return cls(tuple(int(round(voltage / resolution)) for voltage in voltages))
+
+
+@dataclass
+class LinSpaceNode:
+    """AST node for a program that supports linear spacing of set points as well as nested sequencing and repetitions"""
+
+    def dependencies(self) -> Mapping[int, set]:
+        raise NotImplementedError
+
+
+@dataclass
+class LinSpaceHold(LinSpaceNode):
+    """Hold voltages for a given time. The voltages and the time may depend on the iteration index."""
+
+    bases: Tuple[float, ...]
+    factors: Tuple[Optional[Tuple[float, ...]], ...]
+
+    duration_base: TimeType
+    duration_factors: Optional[Tuple[TimeType, ...]]
+
+    def dependencies(self) -> Mapping[int, set]:
+        return {idx: {factors}
+                for idx, factors in enumerate(self.factors)
+                if factors}
+
+
+@dataclass
+class LinSpaceArbitraryWaveform(LinSpaceNode):
+    """This is just a wrapper to pipe arbitrary waveforms through the system."""
+    waveform: Waveform
+    channels: Tuple[ChannelID, ...]
+
+
+@dataclass
+class LinSpaceRepeat(LinSpaceNode):
+    """Repeat the body count times."""
+    body: Tuple[LinSpaceNode, ...]
+    count: int
+
+    def dependencies(self):
+        dependencies = {}
+        for node in self.body:
+            for idx, deps in node.dependencies().items():
+                dependencies.setdefault(idx, set()).update(deps)
+        return dependencies
+
+
+@dataclass
+class LinSpaceIter(LinSpaceNode):
+    """Iteration in linear space are restricted to range 0 to length.
+
+    Offsets and spacing are stored in the hold node."""
+    body: Tuple[LinSpaceNode, ...]
+    length: int
+
+    def dependencies(self):
+        dependencies = {}
+        for node in self.body:
+            for idx, deps in node.dependencies().items():
+                # remove the last elemt in index because this iteration sets it -> no external dependency
+                shortened = {dep[:-1] for dep in deps}
+                if shortened != {()}:
+                    dependencies.setdefault(idx, set()).update(shortened)
+        return dependencies
+
+
+class LinSpaceBuilder(ProgramBuilder):
+    """This program builder supports efficient translation of pulse templates that use symbolic linearly
+    spaced voltages and durations.
+
+    The channel identifiers are reduced to their index in the given channel tuple.
+
+    Arbitrary waveforms are not implemented yet
+    """
+
+    def __init__(self, channels: Tuple[ChannelID, ...]):
+        super().__init__()
+        self._name_to_idx = {name: idx for idx, name in enumerate(channels)}
+        self._idx_to_name = channels
+
+        self._stack = [[]]
+        self._ranges = []
+
+    def _root(self):
+        return self._stack[0]
+
+    def _get_rng(self, idx_name: str) -> range:
+        return self._get_ranges()[idx_name]
+
+    def inner_scope(self, scope: Scope) -> Scope:
+        """This function is necessary to inject program builder specific parameter implementations into the build
+        process."""
+        if self._ranges:
+            name, _ = self._ranges[-1]
+            return scope.overwrite({name: SimpleExpression(base=0, offsets={name: 1})})
+        else:
+            return scope
+
+    def _get_ranges(self):
+        return dict(self._ranges)
+
+    def hold_voltage(self, duration: HardwareTime, voltages: Mapping[ChannelID, HardwareVoltage]):
+        voltages = sorted((self._name_to_idx[ch_name], value) for ch_name, value in voltages.items())
+        voltages = [value for _, value in voltages]
+
+        ranges = self._get_ranges()
+        factors = []
+        bases = []
+        for value in voltages:
+            if isinstance(value, float):
+                bases.append(value)
+                factors.append(None)
+                continue
+            offsets = value.offsets
+            base = value.base
+            incs = []
+            for rng_name, rng in ranges.items():
+                start = 0.
+                step = 0.
+                offset = offsets.get(rng_name, None)
+                if offset:
+                    start += rng.start * offset
+                    step += rng.step * offset
+                base += start
+                incs.append(step)
+            factors.append(tuple(incs))
+            bases.append(base)
+
+        if isinstance(duration, SimpleExpression):
+            duration_factors = duration.offsets
+            duration_base = duration.base
+        else:
+            duration_base = duration
+            duration_factors = None
+
+        set_cmd = LinSpaceHold(bases=tuple(bases),
+                               factors=tuple(factors),
+                               duration_base=duration_base,
+                               duration_factors=duration_factors)
+
+        self._stack[-1].append(set_cmd)
+
+    def play_arbitrary_waveform(self, waveform: Waveform):
+        return self._stack[-1].append(LinSpaceArbitraryWaveform(waveform, self._idx_to_name))
+
+    def measure(self, measurements: Optional[Sequence[MeasurementWindow]]):
+        """Ignores measurements"""
+        pass
+
+    def with_repetition(self, repetition_count: RepetitionCount,
+                        measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']:
+        if repetition_count == 0:
+            return
+        self._stack.append([])
+        yield self
+        blocks = self._stack.pop()
+        if blocks:
+            self._stack[-1].append(LinSpaceRepeat(body=tuple(blocks), count=repetition_count))
+
+    @contextlib.contextmanager
+    def with_sequence(self,
+                      measurements: Optional[Sequence[MeasurementWindow]] = None) -> ContextManager['ProgramBuilder']:
+        yield self
+
+    def new_subprogram(self, global_transformation: 'Transformation' = None) -> ContextManager['ProgramBuilder']:
+        raise NotImplementedError('Not implemented yet (postponed)')
+
+    def with_iteration(self, index_name: str, rng: range,
+                       measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']:
+        if len(rng) == 0:
+            return
+        self._stack.append([])
+        self._ranges.append((index_name, rng))
+        yield self
+        cmds = self._stack.pop()
+        self._ranges.pop()
+        if cmds:
+            self._stack[-1].append(LinSpaceIter(body=tuple(cmds), length=len(rng)))
+
+    def to_program(self) -> Optional[Sequence[LinSpaceNode]]:
+        if self._root():
+            return self._root()
+
+
+@dataclass
+class LoopLabel:
+    idx: int
+    count: int
+
+
+@dataclass
+class Increment:
+    channel: int
+    value: float
+    dependency_key: DepKey
+
+
+@dataclass
+class Set:
+    channel: int
+    value: float
+    key: DepKey = dataclasses.field(default_factory=lambda: DepKey(()))
+
+
+@dataclass
+class Wait:
+    duration: TimeType
+
+
+@dataclass
+class LoopJmp:
+    idx: int
+
+
+@dataclass
+class Play:
+    waveform: Waveform
+    channels: Tuple[ChannelID]
+
+
+Command = Union[Increment, Set, LoopLabel, LoopJmp, Wait, Play]
+
+
+@dataclass(frozen=True)
+class DepState:
+    base: float
+    iterations: Tuple[int, ...]
+
+    def required_increment_from(self, previous: 'DepState', factors: Sequence[float]) -> float:
+        assert len(self.iterations) == len(previous.iterations)
+        assert len(self.iterations) == len(factors)
+
+        increment = self.base - previous.base
+        for old, new, factor in zip(previous.iterations, self.iterations, factors):
+            # By convention there are only two possible values for each integer here: 0 or the last index
+            # The three possible increments are none, regular and jump to next line
+
+            if old == new:
+                # we are still in the same iteration of this sweep
+                pass
+
+            elif old < new:
+                assert old == 0
+                # regular iteration, although the new value will probably be > 1, the resulting increment will be
+                # applied multiple times so only one factor is needed.
+                increment += factor
+
+            else:
+                assert new == 0
+                # we need to jump back. The old value gives us the number of increments to reverse
+                increment -= factor * old
+        return increment
+
+
+@dataclass
+class _TranslationState:
+    """This is the state of a translation of a LinSpace program to a command sequence."""
+
+    label_num: int = dataclasses.field(default=0)
+    commands: List[Command] = dataclasses.field(default_factory=list)
+    iterations: List[int] = dataclasses.field(default_factory=list)
+    active_dep: Dict[int, DepKey] = dataclasses.field(default_factory=dict)
+    dep_states: Dict[int, Dict[DepKey, DepState]] = dataclasses.field(default_factory=dict)
+    plain_voltage: Dict[int, float] = dataclasses.field(default_factory=dict)
+    resolution: float = dataclasses.field(default_factory=lambda: DEFAULT_INCREMENT_RESOLUTION)
+
+    def new_loop(self, count: int):
+        label = LoopLabel(self.label_num, count)
+        jmp = LoopJmp(self.label_num)
+        self.label_num += 1
+        return label, jmp
+
+    def get_dependency_state(self, dependencies: Mapping[int, set]):
+        return {
+            self.dep_states.get(ch, {}).get(DepKey.from_voltages(dep, self.resolution), None)
+            for ch, deps in dependencies.items()
+            for dep in deps
+        }
+
+    def set_voltage(self, channel: int, value: float):
+        key = DepKey(())
+        if self.active_dep.get(channel, None) != key or self.plain_voltage.get(channel, None) != value:
+            self.commands.append(Set(channel, value, key))
+            self.active_dep[channel] = key
+            self.plain_voltage[channel] = value
+
+    def _add_repetition_node(self, node: LinSpaceRepeat):
+        pre_dep_state = self.get_dependency_state(node.dependencies())
+        label, jmp = self.new_loop(node.count)
+        initial_position = len(self.commands)
+        self.commands.append(label)
+        self.add_node(node.body)
+        post_dep_state = self.get_dependency_state(node.dependencies())
+        if pre_dep_state != post_dep_state:
+            # hackedy
+            self.commands.pop(initial_position)
+            self.commands.append(label)
+            label.count -= 1
+            self.add_node(node.body)
+        self.commands.append(jmp)
+
+    def _add_iteration_node(self, node: LinSpaceIter):
+        self.iterations.append(0)
+        self.add_node(node.body)
+
+        if node.length > 1:
+            self.iterations[-1] = node.length
+            label, jmp = self.new_loop(node.length - 1)
+            self.commands.append(label)
+            self.add_node(node.body)
+            self.commands.append(jmp)
+        self.iterations.pop()
+
+    def _set_indexed_voltage(self, channel: int, base: float, factors: Sequence[float]):
+        dep_key = DepKey.from_voltages(voltages=factors, resolution=self.resolution)
+        new_dep_state = DepState(
+            base,
+            iterations=tuple(self.iterations)
+        )
+
+        current_dep_state = self.dep_states.setdefault(channel, {}).get(dep_key, None)
+        if current_dep_state is None:
+            assert all(it == 0 for it in self.iterations)
+            self.commands.append(Set(channel, base, dep_key))
+            self.active_dep[channel] = dep_key
+
+        else:
+            inc = new_dep_state.required_increment_from(previous=current_dep_state, factors=factors)
+
+            # we insert all inc here (also inc == 0) because it signals to activate this amplitude register
+            if inc or self.active_dep.get(channel, None) != dep_key:
+                self.commands.append(Increment(channel, inc, dep_key))
+            self.active_dep[channel] = dep_key
+        self.dep_states[channel][dep_key] = new_dep_state
+
+    def _add_hold_node(self, node: LinSpaceHold):
+        if node.duration_factors:
+            raise NotImplementedError("TODO")
+
+        for ch, (base, factors) in enumerate(zip(node.bases, node.factors)):
+            if factors is None:
+                self.set_voltage(ch, base)
+                continue
+
+            else:
+                self._set_indexed_voltage(ch, base, factors)
+
+        self.commands.append(Wait(node.duration_base))
+
+    def add_node(self, node: Union[LinSpaceNode, Sequence[LinSpaceNode]]):
+        """Translate a (sequence of) linspace node(s) to commands and add it to the internal command list."""
+        if isinstance(node, Sequence):
+            for lin_node in node:
+                self.add_node(lin_node)
+
+        elif isinstance(node, LinSpaceRepeat):
+            self._add_repetition_node(node)
+
+        elif isinstance(node, LinSpaceIter):
+            self._add_iteration_node(node)
+
+        elif isinstance(node, LinSpaceHold):
+            self._add_hold_node(node)
+
+        elif isinstance(node, LinSpaceArbitraryWaveform):
+            self.commands.append(Play(node.waveform, node.channels))
+
+        else:
+            raise TypeError("The node type is not handled", type(node), node)
+
+
+def to_increment_commands(linspace_nodes: Sequence[LinSpaceNode]) -> List[Command]:
+    """translate the given linspace node tree to a minimal sequence of set and increment commands as well as loops."""
+    state = _TranslationState()
+    state.add_node(linspace_nodes)
+    return state.commands
+
diff --git a/qupulse/program/loop.py b/qupulse/program/loop.py
new file mode 100644
index 000000000..0f0356531
--- /dev/null
+++ b/qupulse/program/loop.py
@@ -0,0 +1,857 @@
+import reprlib
+import warnings
+from collections import defaultdict
+from contextlib import contextmanager
+from dataclasses import dataclass
+from enum import Enum
+from typing import *
+
+import numpy as np
+
+from qupulse.parameter_scope import Scope
+from qupulse.program import ProgramBuilder, RepetitionCount, HardwareTime, HardwareVoltage
+from qupulse.program.transformation import Transformation
+from qupulse.program.volatile import VolatileRepetitionCount, VolatileProperty
+from qupulse.program.waveforms import SequenceWaveform, RepetitionWaveform
+from qupulse.program.waveforms import TransformingWaveform
+from qupulse.program.waveforms import Waveform, ConstantWaveform
+from qupulse.pulses.range import RangeScope
+from qupulse.utils import is_integer
+from qupulse.utils.numeric import smallest_factor_ge
+from qupulse.utils.tree import Node
+from qupulse.utils.types import TimeType, MeasurementWindow
+
+__all__ = ['Loop', 'make_compatible', 'MakeCompatibleWarning', 'to_waveform']
+
+
+DurationStructure = Tuple[int, Union[TimeType, 'DurationStructure']]
+
+
+class Loop(Node):
+    MAX_REPR_SIZE = 2000
+    __slots__ = ('_waveform', '_measurements', '_repetition_definition', '_cached_body_duration')
+
+    """This class represents a initialized (sub-)program as a tree. Each Loop of a valid program has a repetition count
+    and either a waveform or a sequence of loops as children.
+    
+    A Loop can have associated measurements which are also repeated.
+    """
+    def __init__(self,
+                 parent: Union['Loop', None] = None,
+                 children: Iterable['Loop'] = (),
+                 waveform: Optional[Waveform] = None,
+                 measurements: Optional[List[MeasurementWindow]] = None,
+                 repetition_count: Union[int, VolatileRepetitionCount] = 1):
+        """Initialize a new loop
+
+        Args:
+            parent: Forwarded to Node.__init__
+            children: Forwarded to Node.__init__
+            waveform: "Payload"
+            measurements: Associated measurements
+            repetition_count: The children / waveform are repeated this often
+        """
+        super().__init__(parent=parent, children=children)
+
+        self._waveform = waveform
+        self._measurements = measurements
+        self._repetition_definition = repetition_count
+        self._cached_body_duration = None
+        assert isinstance(repetition_count, VolatileRepetitionCount) or is_integer(repetition_count)
+        assert isinstance(waveform, (type(None), Waveform))
+
+    def __eq__(self, other: 'Loop') -> bool:
+        if type(self) == type(other):
+            return (self._repetition_definition == other._repetition_definition and
+                    self.waveform == other.waveform and
+                    (self._measurements or None) == (other._measurements or None) and
+                    len(self) == len(other) and
+                    all(self_child == other_child for self_child, other_child in zip(self, other)))
+        else:
+            return NotImplemented
+
+    def append_child(self, loop: Optional['Loop'] = None, **kwargs) -> None:
+        """Append a child to this loop. Either an existing Loop object or a newly created from kwargs
+
+        Args:
+            loop: loop to append
+            **kwargs: Child is constructed with these kwargs
+
+        Raises:
+            ValueError: if called with loop and kwargs
+        """
+        if loop is not None:
+            if kwargs:
+                raise ValueError("Cannot pass a Loop object and Loop constructor arguments at the same time in "
+                                 "append_child")
+            arg = (loop,)
+        else:
+            arg = (kwargs,)
+        super().__setitem__(slice(len(self), len(self)), arg)
+        self._invalidate_duration(body_duration_increment=self[-1].duration)
+
+    def _invalidate_duration(self, body_duration_increment=None):
+        if self._cached_body_duration is not None:
+            if body_duration_increment is not None:
+                self._cached_body_duration += body_duration_increment
+            else:
+                self._cached_body_duration = None
+        if self.parent:
+            if body_duration_increment is not None:
+                self.parent._invalidate_duration(body_duration_increment=body_duration_increment*self.repetition_count)
+            else:
+                self.parent._invalidate_duration()
+
+    def add_measurements(self, measurements: Iterable[MeasurementWindow]):
+        """Add measurements offset by the current body duration i.e. to the END of the current loop
+
+        Args:
+            measurements: Measurements to add
+        """
+        body_duration = float(self.body_duration)
+        if body_duration == 0:
+            measurements = measurements
+        else:
+            measurements = ((mw_name, begin+body_duration, length) for mw_name, begin, length in measurements)
+
+        if self._measurements is None:
+            self._measurements = list(measurements)
+        else:
+            self._measurements.extend(measurements)
+
+    @property
+    def waveform(self) -> Waveform:
+        return self._waveform
+
+    @waveform.setter
+    def waveform(self, val) -> None:
+        self._waveform = val
+        self._invalidate_duration()
+
+    @property
+    def body_duration(self) -> TimeType:
+        if self._cached_body_duration is None:
+            if self.is_leaf():
+                if self.waveform:
+                    self._cached_body_duration = self.waveform.duration
+                else:
+                    self._cached_body_duration = TimeType.from_fraction(0, 1)
+            else:
+                self._cached_body_duration = sum(child.duration for child in self)
+        return self._cached_body_duration
+
+    @property
+    def duration(self) -> TimeType:
+        return self.body_duration * TimeType.from_fraction(self.repetition_count, 1)
+
+    @property
+    def volatile_repetition(self) -> Optional[VolatileProperty]:
+        return getattr(self._repetition_definition, 'volatile_property', None)
+
+    @property
+    def repetition_definition(self) -> Union[int, VolatileRepetitionCount]:
+        return self._repetition_definition
+
+    @repetition_definition.setter
+    def repetition_definition(self, new_definition: Union[int, VolatileRepetitionCount]):
+        self._repetition_definition = new_definition
+
+    @property
+    def repetition_count(self) -> int:
+        return int(self._repetition_definition)
+
+    @repetition_count.setter
+    def repetition_count(self, val: int) -> None:
+        assert isinstance(val, (int, float))
+        new_repetition = int(val)
+        if abs(new_repetition - val) > 1e-10:
+            raise ValueError('Repetition count was not an integer')
+        self._repetition_definition = new_repetition
+
+    def unroll(self) -> None:
+        if self.is_leaf():
+            raise RuntimeError('Leaves cannot be unrolled')
+        if self.volatile_repetition:
+            warnings.warn("Unrolling a Loop with volatile repetition count", VolatileModificationWarning)
+
+        i = self.parent_index
+        self.parent[i:i+1] = (child.copy_tree_structure(new_parent=self.parent)
+                              for _ in range(self.repetition_count)
+                              for child in self)
+        self.parent.assert_tree_integrity()
+
+    def __setitem__(self, idx, value):
+        super().__setitem__(idx, value)
+        self._invalidate_duration()
+
+    def unroll_children(self) -> None:
+        if self.volatile_repetition:
+            warnings.warn("Unrolling a Loop with volatile repetition count", VolatileModificationWarning)
+        old_children = self.children
+        self[:] = (child.copy_tree_structure()
+                   for _ in range(self.repetition_count)
+                   for child in old_children)
+        self.repetition_count = 1
+        self.assert_tree_integrity()
+
+    def encapsulate(self) -> None:
+        """Add a nesting level by moving self to its children."""
+        self[:] = [Loop(children=self,
+                        repetition_count=self._repetition_definition,
+                        waveform=self._waveform,
+                        measurements=self._measurements)]
+        self.repetition_count = 1
+        self._waveform = None
+        self._measurements = None
+        self.assert_tree_integrity()
+
+    def _get_str(self, first_prefix, other_prefixes) -> Generator[str, None, None]:
+        if self.is_leaf():
+            yield '%sEXEC %r %d times' % (first_prefix, self._waveform, self.repetition_count)
+        else:
+            yield '%sLOOP %d times:' % (first_prefix, self.repetition_count)
+
+            for elem in self:
+                yield from cast(Loop, elem)._get_str(other_prefixes + '  ->', other_prefixes + '    ')
+
+    @reprlib.recursive_repr()
+    def __str__(self) -> str:
+        str_len = 0
+        repr_list = []
+        for sub_repr in self._get_str('', ''):
+            str_len += len(sub_repr)
+
+            if self.MAX_REPR_SIZE and str_len > self.MAX_REPR_SIZE:
+                repr_list.append('...')
+                break
+            else:
+                repr_list.append(sub_repr)
+        return '\n'.join(repr_list)
+
+    @reprlib.recursive_repr()
+    def __repr__(self):
+        children = self.children
+        waveform = self.waveform
+        measurements = self._measurements
+        repetition_count = self.repetition_count
+
+        max_repr_size = self.MAX_REPR_SIZE
+
+        reprs = {}
+        if children:
+            if len(children) < max_repr_size // len('Loop(...)'):
+                reprs['children'] = repr(list(children))
+            else:
+                reprs['children'] = '[...]'
+
+        if waveform:
+            waveform_repr = repr(waveform)
+            if len(waveform_repr) >= max_repr_size:
+                waveform_repr = '...'
+            reprs['waveform'] = waveform_repr
+
+        if measurements:
+            meas_repr = repr(measurements)
+            if len(meas_repr) >= max_repr_size:
+                meas_repr = '[...]'
+            reprs['measurements'] = meas_repr
+
+        if repetition_count != 1:
+            reprs['repetition_count'] = repr(repetition_count)
+
+        kwargs = ', '.join(f'{attr}={val}' for attr, val in reprs.items())
+
+        type_name = type(self).__name__
+        max_kwargs = max(self.MAX_REPR_SIZE - len(type_name) - 2, 0)
+
+        if len(kwargs) > max_kwargs:
+            kwargs = '...'
+
+        return f'{type(self).__name__}({kwargs})'
+
+    def copy_tree_structure(self, new_parent: Union['Loop', bool]=False) -> 'Loop':
+        return type(self)(parent=self.parent if new_parent is False else new_parent,
+                          waveform=self._waveform,
+                          repetition_count=self._repetition_definition,
+                          measurements=None if self._measurements is None else list(self._measurements),
+                          children=(child.copy_tree_structure() for child in self))
+
+    def _get_measurement_windows(self, drop: bool) -> Mapping[str, np.ndarray]:
+        """Private implementation of get_measurement_windows with a slightly different data format for easier tiling.
+
+        Args:
+            drop: Drops the measurements from the Loop i.e. the Loop will no longer have measurements attached after
+            collecting them
+
+        Returns:
+             A dictionary (measurement_name -> array) with begin == array[:, 0] and length == array[:, 1]
+        """
+        temp_meas_windows = defaultdict(list)
+        if self._measurements:
+            for (mw_name, begin, length) in self._measurements:
+                temp_meas_windows[mw_name].append((begin, length))
+
+            for mw_name, begin_length_list in temp_meas_windows.items():
+                temp_meas_windows[mw_name] = [np.asarray(begin_length_list, dtype=float)]
+
+            if drop:
+                self._measurements = None
+
+        # calculate duration together with meas windows in the same iteration
+        if self.is_leaf():
+            body_duration = float(self.body_duration)
+        else:
+            offset = TimeType(0)
+            for child in self:
+                for mw_name, begins_length_array in child._get_measurement_windows(drop).items():
+                    begins_length_array[:, 0] += float(offset)
+                    temp_meas_windows[mw_name].append(begins_length_array)
+                offset += child.duration
+
+            body_duration = float(offset)
+
+        # formatting like this for easier debugging
+        result = {}
+
+        # repeat and add repetition based offset
+        for mw_name, begin_length_list in temp_meas_windows.items():
+            result[mw_name] = _repeat_loop_measurements(begin_length_list, self.repetition_count, body_duration)
+
+        return result
+
+    def get_measurement_windows(self, drop=False) -> Dict[str, Tuple[np.ndarray, np.ndarray]]:
+        """Iterates over all children and collect the begin and length arrays of each measurement window.
+
+        Args:
+            drop: Drops the measurements from the Loop i.e. the Loop will no longer have measurements attached after
+            collecting them.
+
+        Returns:
+            A dictionary (measurement_name -> (begin, length)) with begin and length being :class:`numpy.ndarray`
+        """
+        return {mw_name: (begin_length_list[:, 0], begin_length_list[:, 1])
+                for mw_name, begin_length_list in self._get_measurement_windows(drop=drop).items()}
+
+    def split_one_child(self, child_index=None) -> None:
+        """Take the last child that has a repetition count larger one, decrease it's repetition count and insert a copy
+        with repetition cout one after it"""
+        if child_index is not None:
+            if self[child_index].repetition_count < 2:
+                raise ValueError('Cannot split child {} as the repetition count is not larger 1')
+
+        else:
+            # we cannot reverse enumerate
+            n_child = len(self) - 1
+            for reverse_idx, child in enumerate(reversed(self)):
+                if child.repetition_count > 1:
+                    forward_idx = n_child - reverse_idx
+                    if not child.volatile_repetition:
+                        child_index = forward_idx
+                        break
+                    elif child_index is None:
+                        child_index = forward_idx
+            else:
+                if child_index is None:
+                    raise RuntimeError('There is no child with repetition count > 1')
+
+        if self[child_index].volatile_repetition:
+            warnings.warn("Splitting a child with volatile repetition count", VolatileModificationWarning)
+
+        new_child = self[child_index].copy_tree_structure()
+        new_child.repetition_count = 1
+
+        self[child_index].repetition_count -= 1
+
+        self[child_index+1:child_index+1] = (new_child,)
+        self.assert_tree_integrity()
+
+    def flatten_and_balance(self, depth: int) -> None:
+        """Modifies the program so all tree branches have the same depth.
+
+        Args:
+            depth: Target depth of the program
+        """
+        i = 0
+        while i < len(self):
+            # only used by type checker
+            sub_program = cast(Loop, self[i])
+
+            if sub_program.depth() < depth - 1:
+                # increase nesting because the subprogram is not deep enough
+                sub_program.encapsulate()
+
+            elif not sub_program.is_balanced():
+                # balance the sub program. We revisit it in the next iteration (no change of i )
+                # because it might modify self. While writing this comment I am not sure this is true. 14.01.2020 Simon
+                sub_program.flatten_and_balance(depth - 1)
+
+            elif sub_program.depth() == depth - 1:
+                # subprogram is balanced with the correct depth
+                i += 1
+
+            elif sub_program._has_single_child_that_can_be_merged():
+                # subprogram is balanced but to deep and has no measurements -> we can "lift" the sub-sub-program
+                # TODO: There was a len(sub_sub_program) == 1 check here that I cannot explain
+                sub_program._merge_single_child()
+
+            elif not sub_program.is_leaf():
+                # subprogram is balanced but too deep
+                sub_program.unroll()
+
+            else:
+                # we land in this case if the function gets called with depth == 0 and the current subprogram is a leaf
+                i += 1
+
+    def _has_single_child_that_can_be_merged(self) -> bool:
+        """Check if self has only once child which can cheaply be merged into self by multiplying the repetition counts.
+        """
+        if len(self) == 1:
+            child = cast(Loop, self[0])
+            return not self._measurements or (child.repetition_count == 1 and not child.volatile_repetition)
+        else:
+            return False
+
+    def _merge_single_child(self):
+        """Lift the single child to current level. Requires _has_single_child_that_can_be_merged to be true"""
+        assert len(self) == 1, "bug: _merge_single_child called on loop with len != 1"
+        child = cast(Loop, self[0])
+
+        # if the child has a fixed repetition count of 1 the measurements can be merged
+        mergable_measurements = child.repetition_count == 1 and not child.volatile_repetition
+
+        assert not self._measurements or mergable_measurements, "bug: _merge_single_child called on loop with measurements"
+        assert not self._waveform, "bug: _merge_single_child called on loop with children and waveform"
+
+        measurements = child._measurements
+        if self._measurements:
+            if measurements:
+                measurements.extend(self._measurements)
+            else:
+                measurements = self._measurements
+
+        if not self.volatile_repetition and not child.volatile_repetition:
+            # simple integer multiplication
+            repetition_definition = self.repetition_count * child.repetition_count
+        elif not self.volatile_repetition:
+            repetition_definition = child._repetition_definition * self.repetition_count
+        elif not child.volatile_repetition:
+            repetition_definition = self._repetition_definition * child.repetition_count
+        else:
+            # create a new expression that depends on both
+            expression = 'parent_repetition_count * child_repetition_count'
+            repetition_definition = VolatileRepetitionCount.operation(
+                expression=expression,
+                parent_repetition_count=self._repetition_definition,
+                child_repetition_count=child._repetition_definition)
+
+        self[:] = iter(child)
+        self._waveform = child._waveform
+        self._repetition_definition = repetition_definition
+        self._measurements = measurements
+        self._invalidate_duration()
+        return True
+
+    def cleanup(self, actions=('remove_empty_loops', 'merge_single_child')):
+        """Apply the specified actions to cleanup the Loop.
+
+        remove_empty_loops: Remove loops with no children and no waveform (a DroppedMeasurementWarning is issued)
+        merge_single_child: see `_try_merge_single_child` documentation
+
+        Warnings:
+            DroppedMeasurementWarning: Likely a bug in qupulse. TODO: investigate whether there are usecases
+        """
+        if 'remove_empty_loops' in actions:
+            new_children = []
+            for child in self:
+                child = cast(Loop, child)
+                if child.is_leaf():
+                    if child.waveform is None:
+                        if child._measurements:
+                            warnings.warn("Dropping measurement since there is no waveform attached",
+                                          category=DroppedMeasurementWarning)
+                    else:
+                        new_children.append(child)
+
+                else:
+                    child.cleanup(actions)
+                    if child.waveform or not child.is_leaf():
+                        new_children.append(child)
+
+                    elif child._measurements:
+                        warnings.warn("Dropping measurement since there is no waveform in children",
+                                      category=DroppedMeasurementWarning)
+
+            if len(self) != len(new_children):
+                self[:] = new_children
+
+        else:
+            # only do the recursive call
+            for child in self:
+                child.cleanup(actions)
+
+        if 'merge_single_child' in actions and self._has_single_child_that_can_be_merged():
+            self._merge_single_child()
+
+    def get_duration_structure(self) -> DurationStructure:
+        """Returns a tuple that fingerprints the structure of waveform durations and repetitions of self.
+
+        One possible use case is to identify repeated duration structures and reuse the same control flow with
+        differing data.
+        """
+        if self.is_leaf():
+            return self.repetition_count, self.waveform.duration
+        else:
+            return self.repetition_count, tuple(child.get_duration_structure() for child in self)
+
+    def reverse_inplace(self):
+        if self.is_leaf():
+            self._waveform = self._waveform.reversed()
+        else:
+            self._reverse_children()
+            for child in self:
+                child.reverse_inplace()
+        if self._measurements:
+            duration = self.duration
+            self._measurements = [
+                (name, duration - (begin + length), length)
+                for name, begin, length in self._measurements
+            ]
+
+
+def to_waveform(program: Loop) -> Waveform:
+    if program.is_leaf():
+        if program.repetition_count == 1:
+            return program.waveform
+        else:
+            return RepetitionWaveform.from_repetition_count(program.waveform, program.repetition_count)
+    else:
+        if len(program) == 1:
+            sequenced_waveform = to_waveform(cast(Loop, program[0]))
+        else:
+            sequenced_waveform = SequenceWaveform.from_sequence(
+                [to_waveform(cast(Loop, sub_program))
+                 for sub_program in program])
+        if program.repetition_count > 1:
+            return RepetitionWaveform.from_repetition_count(sequenced_waveform, program.repetition_count)
+        else:
+            return sequenced_waveform
+
+
+class _CompatibilityLevel(Enum):
+    compatible = 0
+    action_required = 1
+    incompatible_too_short = 2
+    incompatible_fraction = 3
+    incompatible_quantum = 4
+
+    def is_incompatible(self) -> bool:
+        return self in (self.incompatible_fraction, self.incompatible_quantum, self.incompatible_too_short)
+
+
+def _is_compatible(program: Loop, min_len: int, quantum: int, sample_rate: TimeType) -> _CompatibilityLevel:
+    """ check whether program loop is compatible with awg requirements
+        possible reasons for incompatibility:
+            program shorter than minimum length
+            program duration not an integer
+            program duration not a multiple of quantum """
+    program_duration_in_samples = program.duration * sample_rate
+
+    if program_duration_in_samples.denominator != 1:
+        return _CompatibilityLevel.incompatible_fraction
+
+    if program_duration_in_samples < min_len:
+        return _CompatibilityLevel.incompatible_too_short
+
+    if program_duration_in_samples % quantum > 0:
+        return _CompatibilityLevel.incompatible_quantum
+
+    if program.is_leaf():
+        waveform_duration_in_samples = program.body_duration * sample_rate
+        if waveform_duration_in_samples < min_len or (waveform_duration_in_samples / quantum).denominator != 1:
+            if program.volatile_repetition:
+                warnings.warn("_is_compatible requires an action which drops volatility.",
+                              category=VolatileModificationWarning)
+            return _CompatibilityLevel.action_required
+        else:
+            return _CompatibilityLevel.compatible
+    else:
+        if all(_is_compatible(cast(Loop, sub_program), min_len, quantum, sample_rate) == _CompatibilityLevel.compatible
+               for sub_program in program):
+            return _CompatibilityLevel.compatible
+        else:
+            if program.volatile_repetition:
+                warnings.warn("_is_compatible requires an action which drops volatility.",
+                              category=VolatileModificationWarning)
+            return _CompatibilityLevel.action_required
+
+
+def _make_compatible(program: Loop, min_len: int, quantum: int, sample_rate: TimeType) -> None:
+    if program.is_leaf():
+        program.waveform = to_waveform(program.copy_tree_structure())
+        program.repetition_count = 1
+    else:
+        comp_levels = [_is_compatible(cast(Loop, sub_program), min_len, quantum, sample_rate)
+                       for sub_program in program]
+
+        if any(comp_level.is_incompatible() for comp_level in comp_levels):
+            single_run = program.duration * sample_rate / program.repetition_count
+            if (single_run / quantum).denominator == 1 and single_run >= min_len:
+                # it is enough to concatenate all children
+                new_repetition_definition = program.repetition_definition
+                program.repetition_count = 1
+            else:
+                # we need to concatenate all children and unroll
+                new_repetition_definition = 1
+
+            program.waveform = to_waveform(program.copy_tree_structure())
+            program.repetition_definition = new_repetition_definition
+            program[:] = []
+            return
+        else:
+            for sub_program, comp_level in zip(program, comp_levels):
+                if comp_level == _CompatibilityLevel.action_required:
+                    _make_compatible(sub_program, min_len, quantum, sample_rate)
+
+
+def make_compatible(program: Loop, minimal_waveform_length: int, waveform_quantum: int, sample_rate: TimeType):
+    """ check program for compatibility to AWG requirements, make it compatible if necessary and  possible"""
+    comp_level = _is_compatible(program,
+                                min_len=minimal_waveform_length,
+                                quantum=waveform_quantum,
+                                sample_rate=sample_rate)
+    if comp_level == _CompatibilityLevel.incompatible_fraction:
+        raise ValueError('The program duration in samples {} is not an integer'.format(program.duration * sample_rate))
+    if comp_level == _CompatibilityLevel.incompatible_too_short:
+        raise ValueError('The program is too short to be a valid waveform. \n'
+                         ' program duration in samples: {} \n'
+                         ' minimal length: {}'.format(program.duration * sample_rate, minimal_waveform_length))
+    if comp_level == _CompatibilityLevel.incompatible_quantum:
+        raise ValueError('The program duration in samples {} '
+                         'is not a multiple of quantum {}'.format(program.duration * sample_rate, waveform_quantum))
+
+    elif comp_level == _CompatibilityLevel.action_required:
+        warnings.warn("qupulse will now concatenate waveforms to make the pulse/program compatible with the chosen AWG."
+                      " This might take some time. If you need this pulse more often it makes sense to write it in a "
+                      "way which is more AWG friendly.", MakeCompatibleWarning)
+
+        _make_compatible(program,
+                         min_len=minimal_waveform_length,
+                         quantum=waveform_quantum,
+                         sample_rate=sample_rate)
+
+    else:
+        assert comp_level == _CompatibilityLevel.compatible
+
+
+def roll_constant_waveforms(program: Loop, minimal_waveform_quanta: int, waveform_quantum: int, sample_rate: TimeType):
+    """This function finds waveforms in program that can be replaced with repetitions of shorter waveforms and replaces
+    them. Complexity O(N_waveforms). Drops measurements because they are not correctly handled here for performance
+    reasons.
+
+    This is possible if:
+     - The waveform is constant on all channels
+     - waveform.duration * sample_rate / waveform_quantum has a factor that is bigger than minimal_waveform_quanta
+
+    Args:
+        program:
+        minimal_waveform_quanta:
+        waveform_quantum:
+        sample_rate:
+
+    Warnings:
+        DroppedMeasurementWarning: This warning is raised if a measurement is dropped.
+    """
+    if program._measurements:
+        warnings.warn("Dropping measurements. Remove measurements before calling roll_constant_waveforms by calling"
+                      " get_measurement_windows(drop=True).", category=DroppedMeasurementWarning)
+        program._measurements = None
+
+    waveform = program.waveform
+
+    if waveform is None:
+        for child in program:
+            roll_constant_waveforms(child, minimal_waveform_quanta, waveform_quantum, sample_rate)
+    else:
+        waveform_quanta = (waveform.duration * sample_rate) // waveform_quantum
+
+        # example
+        # waveform_quanta = 15
+        # minimal_waveform_quanta = 2
+        # => repetition_count = 5, new_waveform_quanta = 3
+        if waveform_quanta < minimal_waveform_quanta * 2:
+            # there is no way to roll this waveform because it is too short
+            return
+
+        const_values = waveform.constant_value_dict()
+        if const_values is None:
+            # The waveform is not constant
+            return
+
+        new_waveform_quanta = smallest_factor_ge(waveform_quanta, min_factor=minimal_waveform_quanta)
+        if new_waveform_quanta == waveform_quanta:
+            # the waveform duration in samples has no suitable factor
+            # TODO: Option to insert multiple Loop objects
+            return
+
+        additional_repetition_count = waveform_quanta // new_waveform_quanta
+
+        new_waveform = ConstantWaveform.from_mapping(
+            duration=waveform_quantum * new_waveform_quanta / sample_rate,
+            constant_values=const_values)
+
+        # use the private properties to avoid invalidating the duration cache of the parent loop
+        program._repetition_definition = program.repetition_definition * additional_repetition_count
+        program._waveform = new_waveform
+
+
+def _repeat_loop_measurements(begin_length_list: List[np.ndarray],
+                              repetition_count: int,
+                              body_duration: float
+                              ) -> np.ndarray:
+    temp_begin_length_array = np.concatenate(begin_length_list)
+
+    begin_length_array = np.tile(temp_begin_length_array, (repetition_count, 1))
+
+    shaped_begin_length_array = np.reshape(begin_length_array, (repetition_count, -1, 2))
+
+    shaped_begin_length_array[:, :, 0] += (np.arange(repetition_count) * body_duration)[:, np.newaxis]
+
+    return begin_length_array
+
+
+class MakeCompatibleWarning(ResourceWarning):
+    pass
+
+
+class VolatileModificationWarning(RuntimeWarning):
+    """This warning is emitted if the colatile part of a program gets modified. This might imply that the volatile
+    parameter cannot be change anymore."""
+
+
+class DroppedMeasurementWarning(RuntimeWarning):
+    """This warning is emitted if a measurement was dropped because there was no waveform attached."""
+
+
+@dataclass
+class LoopGuard:
+    loop: Loop
+    measurements: Optional[List[MeasurementWindow]]
+
+    def append_child(self, **kwargs):
+        if self.measurements:
+            self.loop.add_measurements(self.measurements)
+            self.measurements = None
+        self.loop.append_child(**kwargs)
+
+    def add_measurements(self, measurements: List[MeasurementWindow]):
+        if self.measurements is None:
+            self.measurements = measurements
+        else:
+            self.measurements.extend(measurements)
+
+
+@dataclass
+class StackFrame:
+    loop: Union[Loop, LoopGuard]
+
+    iterating: Optional[Tuple[str, int]]
+
+
+class LoopBuilder(ProgramBuilder):
+    """
+
+    Notes fduring implementation:
+     - This program builder does not use the Loop class to generate the measurements
+
+    """
+
+    def __init__(self):
+        self._root: Loop = Loop()
+        self._top: Union[Loop, LoopGuard] = self._root
+
+        self._stack: List[StackFrame] = [StackFrame(self._root, None)]
+
+    def inner_scope(self, scope: Scope) -> Scope:
+        local_vars = self._stack[-1].iterating
+        if local_vars is None:
+            return scope
+        else:
+            return RangeScope(scope, *local_vars)
+
+    def _push(self, stack_entry: StackFrame):
+        self._top = stack_entry.loop
+        self._stack.append(stack_entry)
+
+    def _pop(self):
+        stack = self._stack
+        stack.pop()
+        self._top = stack[-1].loop
+
+    def _try_append(self, loop, measurements):
+        if loop.waveform or len(loop) != 0:
+            if measurements is not None:
+                self._top.add_measurements(measurements)
+            self._top.append_child(loop=loop)
+
+    def measure(self, measurements: Optional[Sequence[MeasurementWindow]]):
+        if measurements:
+            self._top.add_measurements(measurements)
+
+    def with_repetition(self, repetition_count: RepetitionCount,
+                        measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']:
+        repetition_loop = Loop(repetition_count=repetition_count)
+        self._push(StackFrame(repetition_loop, None))
+        yield self
+        self._pop()
+        self._try_append(repetition_loop, measurements)
+
+    def with_iteration(self, index_name: str, rng: range,
+                       measurements: Optional[Sequence[MeasurementWindow]] = None) -> Iterable['ProgramBuilder']:
+        with self.with_sequence(measurements):
+            top_frame = self._stack[-1]
+            for value in rng:
+                top_frame.iterating = (index_name, value)
+                yield self
+
+    @contextmanager
+    def with_sequence(self, measurements: Optional[Sequence[MeasurementWindow]] = None) -> ContextManager['ProgramBuilder']:
+        top_frame = StackFrame(LoopGuard(self._top, measurements), None)
+        self._push(top_frame)
+        yield self
+        self._pop()
+
+    def hold_voltage(self, duration: HardwareTime, voltages: Mapping[str, HardwareVoltage]):
+        self.play_arbitrary_waveform(ConstantWaveform.from_mapping(duration, voltages))
+
+    def play_arbitrary_waveform(self, waveform: Waveform):
+        self._top.append_child(waveform=waveform)
+
+    @contextmanager
+    def new_subprogram(self, global_transformation: Transformation = None) -> ContextManager['ProgramBuilder']:
+        inner_builder = LoopBuilder()
+        yield inner_builder
+        inner_program = inner_builder.to_program()
+
+        if inner_program is not None:
+            measurements = [(name, begin, length)
+                            for name, (begins, lengths) in inner_program.get_measurement_windows().items()
+                            for begin, length in zip(begins, lengths)]
+            self._top.add_measurements(measurements)
+            waveform = to_waveform(inner_program)
+            if global_transformation is not None:
+                waveform = TransformingWaveform.from_transformation(waveform, global_transformation)
+            self.play_arbitrary_waveform(waveform)
+
+    def to_program(self) -> Optional[Loop]:
+        if len(self._stack) != 1:
+            warnings.warn("Creating program with active build stack.")
+        if self._root.waveform or len(self._root.children) != 0:
+            return self._root
+
+    @classmethod
+    def _testing_dummy(cls, stack):
+        builder = cls()
+        builder._stack = [StackFrame(loop, None) for loop in stack]
+        builder._root = builder._stack[0].loop
+        builder._top = builder._stack[-1].loop
+        return builder
diff --git a/qupulse/program/transformation.py b/qupulse/program/transformation.py
new file mode 100644
index 000000000..1d3c86879
--- /dev/null
+++ b/qupulse/program/transformation.py
@@ -0,0 +1,408 @@
+from typing import Any, Mapping, Set, Tuple, Sequence, AbstractSet, Union, TYPE_CHECKING, Hashable
+from abc import abstractmethod
+from numbers import Real
+
+import numpy as np
+
+from qupulse import ChannelID
+from qupulse.comparable import Comparable
+from qupulse.utils.types import SingletonABCMeta, frozendict
+from qupulse.expressions import ExpressionScalar
+
+
+_TrafoValue = Union[Real, ExpressionScalar]
+
+
+__all__ = ['Transformation', 'IdentityTransformation', 'LinearTransformation', 'ScalingTransformation',
+           'OffsetTransformation', 'ParallelChannelTransformation', 'ChainedTransformation',
+           'chain_transformations']
+
+
+class Transformation(Comparable):
+    _identity_singleton = None
+    """Transforms numeric time-voltage values for multiple channels to other time-voltage values. The number and names
+     of input and output channels might differ."""
+
+    @abstractmethod
+    def __call__(self, time: Union[np.ndarray, float],
+                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
+        """Apply transformation to data
+        Args:
+            time:
+            data:
+
+        Returns:
+            transformed: A DataFrame that has been transformed with index == output_channels
+        """
+
+    @abstractmethod
+    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        """Return the channel identifiers"""
+
+    @abstractmethod
+    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        """Channels that are required for getting data for the requested output channel"""
+
+    def chain(self, next_transformation: 'Transformation') -> 'Transformation':
+        if next_transformation is IdentityTransformation():
+            return self
+        else:
+            return chain_transformations(self, next_transformation)
+
+    def is_constant_invariant(self):
+        """Signals if the transformation always maps constants to constants."""
+        return False
+
+    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return frozenset()
+
+
+class IdentityTransformation(Transformation, metaclass=SingletonABCMeta):
+    def __call__(self, time: Union[np.ndarray, float],
+                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
+        return data
+
+    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return input_channels
+
+    @property
+    def compare_key(self) -> None:
+        return None
+
+    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return output_channels
+
+    def chain(self, next_transformation: Transformation) -> Transformation:
+        return next_transformation
+
+    def __repr__(self):
+        return 'IdentityTransformation()'
+
+    def is_constant_invariant(self):
+        """Signals if the transformation always maps constants to constants."""
+        return True
+
+    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return input_channels
+
+
+class ChainedTransformation(Transformation):
+    def __init__(self, *transformations: Transformation):
+        self._transformations = transformations
+
+    @property
+    def transformations(self) -> Tuple[Transformation, ...]:
+        return self._transformations
+
+    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        for transformation in self._transformations:
+            input_channels = transformation.get_output_channels(input_channels)
+        return input_channels
+
+    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        for transformation in reversed(self._transformations):
+            output_channels = transformation.get_input_channels(output_channels)
+        return output_channels
+
+    def __call__(self, time: Union[np.ndarray, float],
+                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
+        for transformation in self._transformations:
+            data = transformation(time, data)
+        return data
+
+    @property
+    def compare_key(self) -> Tuple[Transformation, ...]:
+        return self._transformations
+
+    def chain(self, next_transformation) -> Transformation:
+        return chain_transformations(*self.transformations, next_transformation)
+
+    def __repr__(self):
+        return f'{type(self).__name__}{self._transformations!r}'
+
+    def is_constant_invariant(self):
+        """Signals if the transformation always maps constants to constants."""
+        return all(trafo.is_constant_invariant() for trafo in self._transformations)
+
+    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        for trafo in self._transformations:
+            input_channels = trafo.get_constant_output_channels(input_channels)
+        return input_channels
+
+
+class LinearTransformation(Transformation):
+    def __init__(self,
+                 transformation_matrix: np.ndarray,
+                 input_channels: Sequence[ChannelID],
+                 output_channels: Sequence[ChannelID]):
+        """
+
+        Args:
+            transformation_matrix: Matrix describing the transformation with shape (output_channels, input_channels)
+            input_channels: Channel ids of the columns
+            output_channels: Channel ids of the rows
+        """
+        transformation_matrix = np.asarray(transformation_matrix)
+
+        if transformation_matrix.shape != (len(output_channels), len(input_channels)):
+            raise ValueError('Shape of transformation matrix does not match to the given channels')
+
+        output_sorter = np.argsort(output_channels)
+        transformation_matrix = transformation_matrix[output_sorter, :]
+
+        input_sorter = np.argsort(input_channels)
+        transformation_matrix = transformation_matrix[:, input_sorter]
+
+        self._matrix = transformation_matrix
+        self._input_channels = tuple(sorted(input_channels))
+        self._output_channels = tuple(sorted(output_channels))
+        self._input_channels_set = frozenset(self._input_channels)
+        self._output_channels_set = frozenset(self._output_channels)
+
+    def __call__(self, time: Union[np.ndarray, float],
+                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
+        data_out = {forwarded_channel: data[forwarded_channel]
+                    for forwarded_channel in set(data.keys()).difference(self._input_channels)}
+
+        if len(data_out) == len(data):
+            # only forwarded data
+            return data_out
+
+        try:
+            data_in = np.stack([data[in_channel] for in_channel in self._input_channels])
+        except KeyError as error:
+            raise KeyError('Invalid input channels', set(data.keys()), set(self._input_channels)) from error
+
+        transformed_data = self._matrix @ data_in
+
+        assert transformed_data.shape[0] == len(self._output_channels)
+        for out_channel, transformed_channel_data in zip(self._output_channels, transformed_data):
+            data_out[out_channel] = transformed_channel_data
+
+        return data_out
+
+    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        if not input_channels >= self._input_channels_set:
+            # input_channels is not a superset of the required input channels
+            raise KeyError('Invalid input channels', input_channels, self._input_channels_set)
+
+        return (input_channels - self._input_channels_set) | self._output_channels_set
+
+    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        forwarded = output_channels - self._output_channels_set
+        if not forwarded.isdisjoint(self._input_channels):
+            raise KeyError('Is input channel', forwarded & self._input_channels_set)
+        elif output_channels.isdisjoint(self._output_channels):
+            return output_channels
+        else:
+            return forwarded | self._input_channels_set
+
+    @property
+    def compare_key(self) -> Tuple[Tuple[ChannelID], Tuple[ChannelID], bytes]:
+        return self._input_channels, self._output_channels, self._matrix.tobytes()
+
+    def __repr__(self):
+        return ('LinearTransformation('
+                'transformation_matrix={transformation_matrix},'
+                'input_channels={input_channels},'
+                'output_channels={output_channels})').format(transformation_matrix=self._matrix.tolist(),
+                                                             input_channels=self._input_channels,
+                                                             output_channels=self._output_channels)
+
+    def is_constant_invariant(self):
+        """Signals if the transformation always maps constants to constants."""
+        return True
+
+    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return input_channels
+
+
+class OffsetTransformation(Transformation):
+    def __init__(self, offsets: Mapping[ChannelID, _TrafoValue]):
+        """Adds an offset to each channel specified in offsets.
+
+        Channels not in offsets are forewarded
+
+        Args:
+            offsets: Channel -> offset mapping
+        """
+        self._offsets = frozendict(offsets)
+        assert _are_valid_transformation_expressions(self._offsets), f"Not valid transformation expressions: {self._offsets}"
+
+    def __call__(self, time: Union[np.ndarray, float],
+                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
+        offsets = _instantiate_expression_dict(time, self._offsets)
+        return {channel: channel_values + offsets[channel] if channel in offsets else channel_values
+                for channel, channel_values in data.items()}
+
+    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return output_channels
+
+    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return input_channels
+
+    @property
+    def compare_key(self) -> Hashable:
+        return self._offsets
+
+    def __repr__(self):
+        return f'{type(self).__name__}({dict(self._offsets)!r})'
+
+    def is_constant_invariant(self):
+        """Signals if the transformation always maps constants to constants."""
+        return not _has_time_dependent_values(self._offsets)
+
+    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return _get_constant_output_channels(self._offsets, input_channels)
+
+
+class ScalingTransformation(Transformation):
+    def __init__(self, factors: Mapping[ChannelID, _TrafoValue]):
+        self._factors = frozendict(factors)
+        assert _are_valid_transformation_expressions(self._factors), f"Not valid transformation expressions: {self._factors}"
+
+    def __call__(self, time: Union[np.ndarray, float],
+                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
+        factors = _instantiate_expression_dict(time, self._factors)
+        return {channel: channel_values * factors[channel] if channel in factors else channel_values
+                for channel, channel_values in data.items()}
+
+    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return output_channels
+
+    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return input_channels
+
+    @property
+    def compare_key(self) -> Hashable:
+        return self._factors
+
+    def __repr__(self):
+        return f'{type(self).__name__}({dict(self._factors)!r})'
+
+    def is_constant_invariant(self):
+        """Signals if the transformation always maps constants to constants."""
+        return not _has_time_dependent_values(self._factors)
+
+    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return _get_constant_output_channels(self._factors, input_channels)
+
+
+try:
+    if TYPE_CHECKING:
+        import pandas
+        PandasDataFrameType = pandas.DataFrame
+    else:
+        PandasDataFrameType = Any
+
+    def linear_transformation_from_pandas(transformation: PandasDataFrameType) -> LinearTransformation:
+        """ Creates a LinearTransformation object out of a pandas data frame.
+
+        Args:
+            transformation (pandas.DataFrame): The pandas.DataFrame object out of which a LinearTransformation will be formed.
+
+        Returns:
+            the created LinearTransformation instance
+        """
+        return LinearTransformation(transformation.values, transformation.columns, transformation.index)
+
+    LinearTransformation.from_pandas = linear_transformation_from_pandas
+except ImportError:
+    pass
+
+
+class ParallelChannelTransformation(Transformation):
+    def __init__(self, channels: Mapping[ChannelID, _TrafoValue]):
+        """Set channel values to given values regardless their former existence. The values can be time dependent
+        expressions.
+
+        Args:
+            channels: Channels present in this map are set to the given value.
+        """
+        self._channels: Mapping[ChannelID, _TrafoValue] = frozendict(channels.items())
+        assert _are_valid_transformation_expressions(self._channels), f"Not valid transformation expressions: {self._channels}"
+
+    def __call__(self, time: Union[np.ndarray, float],
+                 data: Mapping[ChannelID, Union[np.ndarray, float]]) -> Mapping[ChannelID, Union[np.ndarray, float]]:
+        overwritten = self._instantiated_values(time)
+        return {**data, **overwritten}
+
+    def _instantiated_values(self, time):
+        scope = {'t': time}
+        array_or_float = lambda x: np.full_like(time, fill_value=x, dtype=float) if hasattr(time, '__len__') else x
+        return {channel: value.evaluate_in_scope(scope) if hasattr(value, 'evaluate_in_scope') else array_or_float(value)
+                for channel, value in self._channels.items()}
+
+    @property
+    def compare_key(self) -> Hashable:
+        return self._channels
+
+    def get_input_channels(self, output_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return output_channels - self._channels.keys()
+
+    def get_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        return input_channels | self._channels.keys()
+
+    def __repr__(self):
+        return f'{type(self).__name__}({dict(self._channels)!r})'
+
+    def is_constant_invariant(self):
+        """Signals if the transformation always maps constants to constants."""
+        return not _has_time_dependent_values(self._channels)
+
+    def get_constant_output_channels(self, input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+        output_channels = set(input_channels)
+        for ch, value in self._channels.items():
+            if hasattr(value, 'variables'):
+                output_channels.discard(ch)
+            else:
+                output_channels.add(ch)
+
+        return output_channels
+
+
+def chain_transformations(*transformations: Transformation) -> Transformation:
+    parsed_transformations = []
+    for transformation in transformations:
+        if transformation is IdentityTransformation() or transformation is None:
+            pass
+        elif isinstance(transformation, ChainedTransformation):
+            parsed_transformations.extend(transformation.transformations)
+        else:
+            parsed_transformations.append(transformation)
+    if len(parsed_transformations) == 0:
+        return IdentityTransformation()
+    elif len(parsed_transformations) == 1:
+        return parsed_transformations[0]
+    else:
+        return ChainedTransformation(*parsed_transformations)
+
+
+def _instantiate_expression_dict(time, expressions: Mapping[str, _TrafoValue]) -> Mapping[str, Union[Real, np.ndarray]]:
+    scope = {'t': time}
+    modified_expressions = {}
+    for name, value in expressions.items():
+        if hasattr(value, 'evaluate_in_scope'):
+            modified_expressions[name] = value.evaluate_in_scope(scope)
+    if modified_expressions:
+        return {**expressions, **modified_expressions}
+    else:
+        return expressions
+
+
+def _has_time_dependent_values(expressions: Mapping[ChannelID, _TrafoValue]) -> bool:
+    return any(hasattr(value, 'variables')
+               for value in expressions.values())
+
+
+def _get_constant_output_channels(expressions: Mapping[ChannelID, _TrafoValue],
+                                  constant_input_channels: AbstractSet[ChannelID]) -> AbstractSet[ChannelID]:
+    return {ch
+            for ch in constant_input_channels
+            if not hasattr(expressions.get(ch, None), 'variables')}
+
+
+def _are_valid_transformation_expressions(expressions: Mapping[ChannelID, _TrafoValue]) -> bool:
+    return all(expr.variables == ('t',)
+               for expr in expressions.values()
+               if hasattr(expr, 'variables'))
diff --git a/qupulse/program/volatile.py b/qupulse/program/volatile.py
new file mode 100644
index 000000000..afd4692ff
--- /dev/null
+++ b/qupulse/program/volatile.py
@@ -0,0 +1,73 @@
+from typing import NamedTuple, Mapping
+import warnings
+import numbers
+
+
+from qupulse.parameter_scope import Scope, MappedScope, JointScope
+from qupulse.expressions import Expression, ExpressionScalar
+from qupulse.utils.types import FrozenDict, FrozenMapping
+from qupulse.utils import is_integer
+
+
+__all__ = ['VolatileProperty', 'VolatileValue', 'VolatileRepetitionCount']
+
+
+VolatileProperty = NamedTuple('VolatileProperty', [('expression', Expression),
+                                                   ('dependencies', FrozenMapping[str, Expression])])
+VolatileProperty.__doc__ = """Hashable representation of a volatile program property. It does not contain the concrete
+value. Using the dependencies attribute to calculate the value might yield unexpected results."""
+
+
+class VolatileValue:
+    """Not hashable"""
+
+    def __init__(self, expression: ExpressionScalar, scope: Scope):
+        self._expression = expression
+        self._scope = scope
+
+    @property
+    def volatile_property(self) -> VolatileProperty:
+        dependencies = self._scope.get_volatile_parameters()
+        dependencies = FrozenDict({parameter_name: dependencies[parameter_name]
+                                   for parameter_name in self._expression.variables
+                                   if parameter_name in dependencies})
+        return VolatileProperty(expression=self._expression, dependencies=dependencies)
+
+    @classmethod
+    def operation(cls, expression, **operands):
+        expression = Expression(expression)
+        assert set(expression.variables) == operands.keys()
+
+        scope = JointScope(FrozenDict(
+            {operand_name: MappedScope(operand._scope, FrozenDict({operand_name: operand._expression}))
+             for operand_name, operand in operands.items()}
+        ))
+        return cls(expression, scope)
+    
+    def __sub__(self, other: int):
+        return type(self)(self._expression - other, self._scope)
+
+    def __mul__(self, other: int):
+        return type(self)(self._expression * other, self._scope)
+
+
+class VolatileRepetitionCount(VolatileValue):
+    def __int__(self):
+        value = self._expression.evaluate_in_scope(self._scope)
+        if not is_integer(value):
+            warnings.warn("Repetition count is no integer. Rounding might lead to unexpected results.")
+        value = int(round(value))
+        if value < 0:
+            warnings.warn("Repetition count is negative. Clamping lead to unexpected results.")
+            value = 0
+        return value
+
+    def update_volatile_dependencies(self, new_constants: Mapping[str, numbers.Number]) -> int:
+        self._scope = self._scope.change_constants(new_constants)
+        return int(self)
+
+    def __eq__(self, other):
+        if type(self) == type(other):
+            return self._scope is other._scope and self._expression == other._expression
+        else:
+            return NotImplemented
diff --git a/qupulse/program/waveforms.py b/qupulse/program/waveforms.py
new file mode 100644
index 000000000..94c395532
--- /dev/null
+++ b/qupulse/program/waveforms.py
@@ -0,0 +1,1237 @@
+"""This module contains all waveform classes
+
+Classes:
+    - Waveform: An instantiated pulse which can be sampled to a raw voltage value array.
+"""
+
+import itertools
+import operator
+import warnings
+from abc import ABCMeta, abstractmethod
+from numbers import Real
+from typing import (
+    AbstractSet, Any, FrozenSet, Iterable, Mapping, NamedTuple, Sequence, Set,
+    Tuple, Union, cast, Optional, List, Hashable)
+from weakref import WeakValueDictionary, ref
+
+import numpy as np
+
+from qupulse import ChannelID
+from qupulse.program.transformation import Transformation
+from qupulse.utils import checked_int_cast, isclose
+from qupulse.utils.types import TimeType, time_from_float
+from qupulse.utils.performance import is_monotonic
+from qupulse.comparable import Comparable
+from qupulse.expressions import ExpressionScalar
+from qupulse.pulses.interpolation import InterpolationStrategy
+from qupulse.utils import checked_int_cast, isclose
+from qupulse.utils.types import TimeType, time_from_float, FrozenDict
+from qupulse.program.transformation import Transformation
+from qupulse.utils import pairwise
+
+class ConstantFunctionPulseTemplateWarning(UserWarning):
+    """  This warning indicates a constant waveform is constructed from a FunctionPulseTemplate """
+    pass
+
+
+__all__ = ["Waveform", "TableWaveform", "TableWaveformEntry", "FunctionWaveform", "SequenceWaveform",
+           "MultiChannelWaveform", "RepetitionWaveform", "TransformingWaveform", "ArithmeticWaveform",
+           "ConstantFunctionPulseTemplateWarning", "ConstantWaveform"]
+
+PULSE_TO_WAVEFORM_ERROR = None  # error margin in pulse template to waveform conversion
+
+#  these are private because there probably will be changes here
+_ALLOCATION_FUNCTION = np.full_like  # pre_allocated = ALLOCATION_FUNCTION(sample_times, **ALLOCATION_FUNCTION_KWARGS)
+_ALLOCATION_FUNCTION_KWARGS = dict(fill_value=np.nan, dtype=float)
+
+
+def _to_time_type(duration: Real) -> TimeType:
+    if isinstance(duration, TimeType):
+        return duration
+    else:
+        return time_from_float(float(duration), absolute_error=PULSE_TO_WAVEFORM_ERROR)
+
+
+class Waveform(Comparable, metaclass=ABCMeta):
+    """Represents an instantiated PulseTemplate which can be sampled to retrieve arrays of voltage
+    values for the hardware."""
+
+    __sampled_cache = WeakValueDictionary()
+
+    __slots__ = ('_duration',)
+
+    def __init__(self, duration: TimeType):
+        self._duration = duration
+
+    @property
+    def duration(self) -> TimeType:
+        """The duration of the waveform in time units."""
+        return self._duration
+
+    @abstractmethod
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        """Sample the waveform at given sample times.
+
+        The unsafe means that there are no sanity checks performed. The provided sample times are assumed to be
+        monotonously increasing and lie in the range of [0, waveform.duration]
+
+        Args:
+            sample_times: Times at which this Waveform will be sampled.
+            output_array: Has to be either None or an array of the same size and type as sample_times. If
+                not None, the sampled values will be written here and this array will be returned
+        Result:
+            The sampled values of this Waveform at the provided sample times. Has the same number of
+            elements as sample_times.
+        """
+
+    def get_sampled(self,
+                    channel: ChannelID,
+                    sample_times: np.ndarray,
+                    output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        """A wrapper to the unsafe_sample method which caches the result. This method enforces the constrains
+        unsafe_sample expects and caches the result to save memory.
+
+        Args:
+            sample_times: Times at which this Waveform will be sampled.
+            output_array: Has to be either None or an array of the same size and type as sample_times. If an array is
+                given, the sampled values will be written into the given array and it will be returned. Otherwise, a new
+                array will be created and cached to save memory.
+
+        Result:
+            The sampled values of this Waveform at the provided sample times. Is `output_array` if provided
+        """
+        if len(sample_times) == 0:
+            if output_array is None:
+                return np.zeros_like(sample_times, dtype=float)
+            elif len(output_array) == len(sample_times):
+                return output_array
+            else:
+                raise ValueError('Output array length and sample time length are different')
+
+        if not is_monotonic(sample_times):
+            raise ValueError('The sample times are not monotonously increasing')
+        if sample_times[0] < 0 or sample_times[-1] > float(self.duration):
+            raise ValueError(f'The sample times [{sample_times[0]}, ..., {sample_times[-1]}] are not in the range'
+                             f' [0, duration={float(self.duration)}]')
+        if channel not in self.defined_channels:
+            raise KeyError('Channel not defined in this waveform: {}'.format(channel))
+
+        constant_value = self.constant_value(channel)
+        if constant_value is None:
+            if output_array is None:
+                # cache the result to save memory
+                result = self.unsafe_sample(channel, sample_times)
+                result.flags.writeable = False
+                key = hash(bytes(result))
+                if key not in self.__sampled_cache:
+                    self.__sampled_cache[key] = result
+                return self.__sampled_cache[key]
+            else:
+                if len(output_array) != len(sample_times):
+                    raise ValueError('Output array length and sample time length are different')
+                # use the user provided memory
+                return self.unsafe_sample(channel=channel,
+                                          sample_times=sample_times,
+                                          output_array=output_array)
+        else:
+            if output_array is None:
+                output_array = np.full_like(sample_times, fill_value=constant_value, dtype=float)
+            else:
+                output_array[:] = constant_value
+            return output_array
+
+    @property
+    @abstractmethod
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        """The channels this waveform should played on. Use
+            :func:`~qupulse.pulses.instructions.get_measurement_windows` to get a waveform for a subset of these."""
+
+    @abstractmethod
+    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
+        """Unsafe version of :func:`~qupulse.pulses.instructions.get_measurement_windows`."""
+
+    def get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
+        """Get a waveform that only describes the channels contained in `channels`.
+
+        Args:
+            channels: A channel set the return value should confine to.
+
+        Raises:
+            KeyError: If `channels` is not a subset of the waveform's defined channels.
+
+        Returns:
+            A waveform with waveform.defined_channels == `channels`
+        """
+        if not channels <= self.defined_channels:
+            raise KeyError('Channels not defined on waveform: {}'.format(channels))
+        if channels == self.defined_channels:
+            return self
+        return self.unsafe_get_subset_for_channels(channels=channels)
+
+    def is_constant(self) -> bool:
+        """Convenience function to check if all channels are constant. The result is equal to
+        `all(waveform.constant_value(ch) is not None for ch in waveform.defined_channels)` but might be more performant.
+
+        Returns:
+            True if all channels have constant values.
+        """
+        return self.constant_value_dict() is not None
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        result = {ch: self.constant_value(ch) for ch in self.defined_channels}
+        if None in result.values():
+            return None
+        else:
+            return result
+
+    def constant_value(self, channel: ChannelID) -> Optional[float]:
+        """Checks if the requested channel has a constant value and returns it if so.
+
+        Guarantee that this assertion passes for every t in waveform duration:
+        >>> assert waveform.constant_value(channel) is None or waveform.constant_value(t) = waveform.get_sampled(channel, t)
+
+        Args:
+            channel: The channel to check
+
+        Returns:
+            None if there is no guarantee that the channel is constant. The value otherwise.
+        """
+        return None
+
+    def __neg__(self):
+        return FunctorWaveform.from_functor(self, {ch: np.negative for ch in self.defined_channels})
+
+    def __pos__(self):
+        return self
+
+    def _sort_key_for_channels(self) -> Sequence[Tuple[str, int]]:
+        """Makes reproducible sorting by defined channels possible"""
+        return sorted((ch, 0) if isinstance(ch, str) else ('', ch) for ch in self.defined_channels)
+
+    def reversed(self) -> 'Waveform':
+        """Returns a reversed version of this waveform."""
+        # We don't check for constness here because const waveforms are supposed to override this method
+        return ReversedWaveform(self)
+
+
+class TableWaveformEntry(NamedTuple('TableWaveformEntry', [('t', Real),
+                                                           ('v', float),
+                                                           ('interp', InterpolationStrategy)])):
+    def __init__(self, t: float, v: float, interp: InterpolationStrategy):
+        if not callable(interp):
+            raise TypeError('{} is neither callable nor of type InterpolationStrategy'.format(interp))
+
+    def __repr__(self):
+        return f'{type(self).__name__}(t={self.t!r}, v={self.v!r}, interp={self.interp!r})'
+
+
+class TableWaveform(Waveform):
+    EntryInInit = Union[TableWaveformEntry, Tuple[float, float, InterpolationStrategy]]
+
+    """Waveform obtained from instantiating a TablePulseTemplate."""
+
+    __slots__ = ('_table', '_channel_id')
+
+    def __init__(self,
+                 channel: ChannelID,
+                 waveform_table: Tuple[TableWaveformEntry, ...]) -> None:
+        """Create a new TableWaveform instance.
+
+        Args:
+            waveform_table: A tuple of instantiated and validated table entries
+        """
+        if not isinstance(waveform_table, tuple):
+            warnings.warn("Please use a tuple of TableWaveformEntry to construct TableWaveform directly",
+                          category=DeprecationWarning)
+            waveform_table = self._validate_input(waveform_table)
+
+        super().__init__(duration=_to_time_type(waveform_table[-1].t))
+
+        self._table = waveform_table
+        self._channel_id = channel
+
+    @staticmethod
+    def _validate_input(input_waveform_table: Sequence[EntryInInit]) -> Union[Tuple[Real, Real],
+                                                                              List[TableWaveformEntry]]:
+        """ Checks that:
+         - the time is increasing,
+         - there are at least two entries
+
+        Optimizations:
+          - removes subsequent entries with same time or voltage values.
+          - checks if the complete waveform is constant. Returns a (duration, value) tuple if this is the case
+
+        Raises:
+            ValueError:
+              - there are less than two entries
+              - the entries are not ordered in time
+              - Any time is negative
+              - The total length is zero
+
+        Returns:
+            A list of de-duplicated table entries
+            OR
+            A (duration, value) tuple if the waveform is constant
+        """
+        # we use an iterator here to avoid duplicate work and be maximally efficient for short tables
+        # We never use StopIteration to abort iteration. It always signifies an error.
+        input_iter = iter(input_waveform_table)
+        try:
+            first_t, first_v, first_interp = next(input_iter)
+        except StopIteration:
+            raise ValueError("Waveform table mut not be empty")
+
+        if first_t != 0.0:
+            raise ValueError('First time entry is not zero.')
+
+        previous_t = 0.0
+        previous_v = first_v
+        output_waveform_table = [TableWaveformEntry(0.0, first_v, first_interp)]
+
+        try:
+            t, v, interp = next(input_iter)
+        except StopIteration:
+            raise ValueError("Waveform table has less than two entries.")
+        if t < 0:
+            raise ValueError('Negative time values are not allowed.')
+
+        # constant_v is None <=> the waveform is constant until up to the current entry
+        constant_v = interp.constant_value((previous_t, previous_v), (t, v))
+
+        for next_t, next_v, next_interp in input_iter:
+            if next_t < t:
+                if next_t < 0:
+                    raise ValueError('Negative time values are not allowed.')
+                else:
+                    raise ValueError('Times are not increasing.')
+
+            if constant_v is not None and interp.constant_value((t, v), (next_t, next_v)) != constant_v:
+                constant_v = None
+
+            if (previous_t != t or t != next_t) and (previous_v != v or v != next_v):
+                # the time and the value differ both either from the next or the previous
+                # otherwise we skip the entry
+                previous_t = t
+                previous_v = v
+                output_waveform_table.append(TableWaveformEntry(t, v, interp))
+
+            t, v, interp = next_t, next_v, next_interp
+
+        # Until now, we only checked that the time does not decrease. We require an increase because duration == 0
+        # waveforms are ill-formed. t is now the time of the last entry.
+        if t == 0:
+            raise ValueError('Last time entry is zero.')
+
+        if constant_v is not None:
+            # the waveform is constant
+            return t, constant_v
+        else:
+            # we must still add the last entry to the table
+            output_waveform_table.append(TableWaveformEntry(t, v, interp))
+            return output_waveform_table
+
+    def is_constant(self) -> bool:
+        # only correct if `from_table` is used
+        return False
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        # only correct if `from_table` is used
+        return None
+
+    @classmethod
+    def from_table(cls, channel: ChannelID, table: Sequence[EntryInInit]) -> Union['TableWaveform', 'ConstantWaveform']:
+        table = cls._validate_input(table)
+        if isinstance(table, tuple):
+            duration, amplitude = table
+            return ConstantWaveform(duration=duration, amplitude=amplitude, channel=channel)
+        else:
+            return TableWaveform(channel, tuple(table))
+
+    @property
+    def compare_key(self) -> Any:
+        return self._channel_id, self._table
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        if output_array is None:
+            output_array = _ALLOCATION_FUNCTION(sample_times, **_ALLOCATION_FUNCTION_KWARGS)
+
+        if PULSE_TO_WAVEFORM_ERROR:
+            # we need to replace the last entry's t with self.duration
+            *entries, last = self._table
+            entries.append(TableWaveformEntry(float(self.duration), last.v, last.interp))
+        else:
+            entries = self._table
+
+        for entry1, entry2 in pairwise(entries):
+            indices = slice(sample_times.searchsorted(entry1.t, 'left'),
+                            sample_times.searchsorted(entry2.t, 'right'))
+            output_array[indices] = \
+                entry2.interp((float(entry1.t), entry1.v),
+                              (float(entry2.t), entry2.v),
+                              sample_times[indices])
+        return output_array
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        return {self._channel_id}
+
+    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
+        return self
+
+    def __repr__(self):
+        return f'{type(self).__name__}(channel={self._channel_id!r}, waveform_table={self._table!r})'
+
+
+class ConstantWaveform(Waveform):
+
+    # TODO: remove
+    _is_constant_waveform = True
+
+    __slots__ = ('_amplitude', '_channel')
+
+    def __init__(self, duration: Real, amplitude: Any, channel: ChannelID):
+        """ Create a qupulse waveform corresponding to a ConstantPulseTemplate """
+        super().__init__(duration=_to_time_type(duration))
+        if hasattr(amplitude, 'shape'):
+            amplitude = amplitude[()]
+            hash(amplitude)
+        self._amplitude = amplitude
+        self._channel = channel
+
+    @classmethod
+    def from_mapping(cls, duration: Real, constant_values: Mapping[ChannelID, float]) -> Union['ConstantWaveform',
+                                                                                               'MultiChannelWaveform']:
+        """Construct a ConstantWaveform or a MultiChannelWaveform of ConstantWaveforms with given duration and values"""
+        assert constant_values
+        duration = _to_time_type(duration)
+        if len(constant_values) == 1:
+            (channel, amplitude), = constant_values.items()
+            return cls(duration, amplitude=amplitude, channel=channel)
+        else:
+            return MultiChannelWaveform([cls(duration, amplitude=amplitude, channel=channel)
+                                         for channel, amplitude in constant_values.items()])
+
+    def is_constant(self) -> bool:
+        return True
+
+    def constant_value(self, channel: ChannelID) -> Optional[float]:
+        assert channel == self._channel
+        return self._amplitude
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        return {self._channel: self._amplitude}
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        """The channels this waveform should played on. Use
+            :func:`~qupulse.pulses.instructions.get_measurement_windows` to get a waveform for a subset of these."""
+
+        return {self._channel}
+
+    @property
+    def compare_key(self) -> Tuple[Any, ...]:
+        return self._duration, self._amplitude, self._channel
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        if output_array is None:
+            return np.full_like(sample_times, fill_value=self._amplitude, dtype=float)
+        else:
+            output_array[:] = self._amplitude
+            return output_array
+
+    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> Waveform:
+        """Unsafe version of :func:`~qupulse.pulses.instructions.get_measurement_windows`."""
+        return self
+
+    def __repr__(self):
+        return f"{type(self).__name__}(duration={self.duration!r}, "\
+               f"amplitude={self._amplitude!r}, channel={self._channel!r})"
+
+    def reversed(self) -> 'Waveform':
+        return self
+
+
+class FunctionWaveform(Waveform):
+    """Waveform obtained from instantiating a FunctionPulseTemplate."""
+
+    __slots__ = ('_expression', '_channel_id')
+
+    def __init__(self, expression: ExpressionScalar,
+                 duration: float,
+                 channel: ChannelID) -> None:
+        """Creates a new FunctionWaveform instance.
+
+        Args:
+            expression: The function represented by this FunctionWaveform
+                as a mathematical expression where 't' denotes the time variable. It must not have other variables
+            duration: The duration of the waveform
+            measurement_windows: A list of measurement windows
+            channel: The channel this waveform is played on
+        """
+
+        if set(expression.variables) - set('t'):
+            raise ValueError('FunctionWaveforms may not depend on anything but "t"')
+        elif not expression.variables:
+            warnings.warn("Constant FunctionWaveform is not recommended as the constant propagation will be suboptimal",
+                          category=ConstantFunctionPulseTemplateWarning)
+        super().__init__(duration=_to_time_type(duration))
+        self._expression = expression
+        self._channel_id = channel
+
+    @classmethod
+    def from_expression(cls, expression: ExpressionScalar, duration: float, channel: ChannelID) -> Union['FunctionWaveform', ConstantWaveform]:
+        if expression.variables:
+            return cls(expression, duration, channel)
+        else:
+            return ConstantWaveform(amplitude=expression.evaluate_numeric(), duration=duration, channel=channel)
+
+    def is_constant(self) -> bool:
+        # only correct if `from_expression` is used
+        return False
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        # only correct if `from_expression` is used
+        return None
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        return {self._channel_id}
+
+    @property
+    def compare_key(self) -> Any:
+        return self._channel_id, self._expression, self._duration
+
+    @property
+    def duration(self) -> TimeType:
+        return self._duration
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        evaluated = self._expression.evaluate_numeric(t=sample_times)
+        if output_array is None:
+            if self._expression.variables:
+                return evaluated.astype(float)
+            else:
+                return np.full_like(sample_times, fill_value=float(evaluated), dtype=float)
+        else:
+            output_array[:] = evaluated
+            return output_array
+
+    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> Waveform:
+        return self
+
+    def __repr__(self):
+        return f"{type(self).__name__}(duration={self.duration!r}, "\
+               f"expression={self._expression!r}, channel={self._channel_id!r})"
+
+
+class SequenceWaveform(Waveform):
+    """This class allows putting multiple PulseTemplate together in one waveform on the hardware."""
+
+    __slots__ = ('_sequenced_waveforms', )
+
+    def __init__(self, sub_waveforms: Iterable[Waveform]):
+        """Use Waveform.from_sequence for optimal construction
+
+        :param subwaveforms: All waveforms must have the same defined channels
+        """
+        if not sub_waveforms:
+            raise ValueError(
+                "SequenceWaveform cannot be constructed without channel waveforms."
+            )
+
+        # do not fail on iterators although we do not allow them as an argument
+        sequenced_waveforms = tuple(sub_waveforms)
+
+        super().__init__(duration=sum(waveform.duration for waveform in sequenced_waveforms))
+        self._sequenced_waveforms = sequenced_waveforms
+
+        defined_channels = self._sequenced_waveforms[0].defined_channels
+        if not all(waveform.defined_channels == defined_channels
+                   for waveform in itertools.islice(self._sequenced_waveforms, 1, None)):
+            for waveform in self._sequenced_waveforms[1:]:
+                 if not waveform.defined_channels == self.defined_channels:
+                     print(f"SequenceWaveform: defined channels {self.defined_channels} do not match {waveform.defined_channels} ")
+            raise ValueError(
+                "SequenceWaveform cannot be constructed from waveforms of different"
+                "defined channels."
+            )
+
+    @classmethod
+    def from_sequence(cls, waveforms: Sequence['Waveform']) -> 'Waveform':
+        """Returns a waveform the represents the given sequence of waveforms. Applies some optimizations."""
+        assert waveforms, "Sequence must not be empty"
+        if len(waveforms) == 1:
+            return waveforms[0]
+
+        flattened = []
+        constant_values = waveforms[0].constant_value_dict()
+        for wf in waveforms:
+            if constant_values and constant_values != wf.constant_value_dict():
+                constant_values = None
+            if isinstance(wf, cls):
+                flattened.extend(wf.sequenced_waveforms)
+            else:
+                flattened.append(wf)
+        if constant_values is None:
+            return cls(sub_waveforms=flattened)
+        else:
+            duration = sum(wf.duration for wf in flattened)
+            return ConstantWaveform.from_mapping(duration, constant_values)
+
+    def is_constant(self) -> bool:
+        # only correct if from_sequence is used for construction
+        return False
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        # only correct if from_sequence is used for construction
+        return None
+
+    def constant_value(self, channel: ChannelID) -> Optional[float]:
+        v = None
+        for wf in self._sequenced_waveforms:
+            wf_cv = wf.constant_value(channel)
+            if wf_cv is None:
+                return None
+            elif wf_cv == v:
+                continue
+            elif v is None:
+                v = wf_cv
+            else:
+                assert v != wf_cv
+                return None
+        return v
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        return self._sequenced_waveforms[0].defined_channels
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        if output_array is None:
+            output_array = _ALLOCATION_FUNCTION(sample_times, **_ALLOCATION_FUNCTION_KWARGS)
+        time = 0
+        for subwaveform in self._sequenced_waveforms:
+            # before you change anything here, make sure to understand the difference between basic and advanced
+            # indexing in numpy and their copy/reference behaviour
+            end = time + subwaveform.duration
+
+            indices = slice(*sample_times.searchsorted((float(time), float(end)), 'left'))
+            subwaveform.unsafe_sample(channel=channel,
+                                      sample_times=sample_times[indices]-np.float64(time),
+                                      output_array=output_array[indices])
+            time = end
+        return output_array
+
+    @property
+    def compare_key(self) -> Tuple[Waveform]:
+        return self._sequenced_waveforms
+
+    @property
+    def duration(self) -> TimeType:
+        return self._duration
+
+    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
+        return SequenceWaveform.from_sequence([
+            sub_waveform.unsafe_get_subset_for_channels(channels & sub_waveform.defined_channels)
+            for sub_waveform in self._sequenced_waveforms if sub_waveform.defined_channels & channels])
+
+    @property
+    def sequenced_waveforms(self) -> Sequence[Waveform]:
+        return self._sequenced_waveforms
+
+    def __repr__(self):
+        return f"{type(self).__name__}({self._sequenced_waveforms})"
+
+
+class MultiChannelWaveform(Waveform):
+    """A MultiChannelWaveform is a Waveform object that allows combining arbitrary Waveform objects
+    to into a single waveform defined for several channels.
+
+    The number of channels used by the MultiChannelWaveform object is the sum of the channels used
+    by the Waveform objects it consists of.
+
+    MultiChannelWaveform allows an arbitrary mapping of channels defined by the Waveforms it
+    consists of and the channels it defines. For example, if the MultiChannelWaveform consists
+    of a two Waveform objects A and B which define two channels each, then the channels of the
+    MultiChannelWaveform may be 0: A.1, 1: B.0, 2: B.1, 3: A.0 where A.0 means channel 0 of Waveform
+    object A.
+
+    The following constraints must hold:
+     - The durations of all Waveform objects must be equal.
+     - The channel mapping must be sane, i.e., no channel of the MultiChannelWaveform must be
+        assigned more than one channel of any Waveform object it consists of
+    """
+
+    __slots__ = ('_sub_waveforms', '_defined_channels')
+
+    def __init__(self, sub_waveforms: List[Waveform]) -> None:
+        """Create a new MultiChannelWaveform instance.
+        Use `MultiChannelWaveform.from_parallel` for optimal construction.
+
+        Requires a list of subwaveforms in the form (Waveform, List(int)) where the list defines
+        the channel mapping, i.e., a value y at index x in the list means that channel x of the
+        subwaveform will be mapped to channel y of this MultiChannelWaveform object.
+
+        Args:
+            sub_waveforms: The list of sub waveforms of this
+                MultiChannelWaveform. List might get sorted!
+        Raises:
+            ValueError, if a channel mapping is out of bounds of the channels defined by this
+                MultiChannelWaveform
+            ValueError, if several subwaveform channels are assigned to a single channel of this
+                MultiChannelWaveform
+            ValueError, if subwaveforms have inconsistent durations
+        """
+
+        if not sub_waveforms:
+            raise ValueError(
+                "MultiChannelWaveform cannot be constructed without channel waveforms."
+            )
+
+        # sort the waveforms with their defined channels to make compare key reproducible
+        if not isinstance(sub_waveforms, list):
+            sub_waveforms = list(sub_waveforms)
+        sub_waveforms.sort(key=lambda wf: wf._sort_key_for_channels())
+
+        super().__init__(duration=sub_waveforms[0].duration)
+        self._sub_waveforms = tuple(sub_waveforms)
+
+        defined_channels = set()
+        for waveform in self._sub_waveforms:
+            if waveform.defined_channels & defined_channels:
+                raise ValueError('Channel may not be defined in multiple waveforms',
+                                 waveform.defined_channels & defined_channels)
+            defined_channels |= waveform.defined_channels
+        self._defined_channels = frozenset(defined_channels)
+
+        if not all(isclose(waveform.duration, self.duration) for waveform in self._sub_waveforms[1:]):
+            # meaningful error message:
+            durations = {}
+
+            for waveform in self._sub_waveforms:
+                for duration, channels in durations.items():
+                    if isclose(waveform.duration, duration):
+                        channels.update(waveform.defined_channels)
+                        break
+                else:
+                    durations[waveform.duration] = set(waveform.defined_channels)
+
+            raise ValueError(
+                "MultiChannelWaveform cannot be constructed from channel waveforms of different durations.",
+                durations
+            )
+
+    @staticmethod
+    def from_parallel(waveforms: Sequence[Waveform]) -> Waveform:
+        assert waveforms, "ARgument must not be empty"
+        if len(waveforms) == 1:
+            return waveforms[0]
+
+        # we do not look at constant values here because there is no benefit. We would need to construct a new
+        # MultiChannelWaveform anyways
+
+        # avoid unnecessary multi channel nesting
+        flattened = []
+        for waveform in waveforms:
+            if isinstance(waveform, MultiChannelWaveform):
+                flattened.extend(waveform._sub_waveforms)
+            else:
+                flattened.append(waveform)
+
+        return MultiChannelWaveform(flattened)
+
+    def is_constant(self) -> bool:
+        return all(wf.is_constant() for wf in self._sub_waveforms)
+
+    def constant_value(self, channel: ChannelID) -> Optional[float]:
+        return self[channel].constant_value(channel)
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        d = {}
+        for wf in self._sub_waveforms:
+            wf_d = wf.constant_value_dict()
+            if wf_d is None:
+                return None
+            else:
+                d.update(wf_d)
+        return d
+
+    @property
+    def duration(self) -> TimeType:
+        return self._sub_waveforms[0].duration
+
+    def __getitem__(self, key: ChannelID) -> Waveform:
+        for waveform in self._sub_waveforms:
+            if key in waveform.defined_channels:
+                return waveform
+        raise KeyError('Unknown channel ID: {}'.format(key), key)
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        return self._defined_channels
+
+    @property
+    def compare_key(self) -> Any:
+        # sort with channels
+        return self._sub_waveforms
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        return self[channel].unsafe_sample(channel, sample_times, output_array)
+
+    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
+        relevant_sub_waveforms = [swf for swf in self._sub_waveforms if swf.defined_channels & channels]
+        if len(relevant_sub_waveforms) == 1:
+            return relevant_sub_waveforms[0].get_subset_for_channels(channels)
+        elif len(relevant_sub_waveforms) > 1:
+            return MultiChannelWaveform.from_parallel(
+                [sub_waveform.get_subset_for_channels(channels & sub_waveform.defined_channels)
+                 for sub_waveform in relevant_sub_waveforms])
+        else:
+            raise KeyError('Unknown channels: {}'.format(channels))
+
+    def __repr__(self):
+        return f"{type(self).__name__}({self._sub_waveforms!r})"
+
+
+class RepetitionWaveform(Waveform):
+    """This class allows putting multiple PulseTemplate together in one waveform on the hardware."""
+
+    __slots__ = ('_body', '_repetition_count')
+
+    def __init__(self, body: Waveform, repetition_count: int):
+        repetition_count = checked_int_cast(repetition_count)
+        if repetition_count < 1 or not isinstance(repetition_count, int):
+            raise ValueError('Repetition count must be an integer >0')
+
+        super().__init__(duration=body.duration * repetition_count)
+        self._body = body
+        self._repetition_count = repetition_count
+
+    @classmethod
+    def from_repetition_count(cls, body: Waveform, repetition_count: int) -> Waveform:
+        constant_values = body.constant_value_dict()
+        if constant_values is None:
+            return RepetitionWaveform(body, repetition_count)
+        else:
+            return ConstantWaveform.from_mapping(body.duration * repetition_count, constant_values)
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        return self._body.defined_channels
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        if output_array is None:
+            output_array = _ALLOCATION_FUNCTION(sample_times, **_ALLOCATION_FUNCTION_KWARGS)
+        body_duration = self._body.duration
+        time = 0
+        for _ in range(self._repetition_count):
+            end = time + body_duration
+            indices = slice(*sample_times.searchsorted((float(time), float(end)), 'left'))
+            self._body.unsafe_sample(channel=channel,
+                                     sample_times=sample_times[indices] - float(time),
+                                     output_array=output_array[indices])
+            time = end
+        return output_array
+
+    @property
+    def compare_key(self) -> Tuple[Any, int]:
+        return self._body.compare_key, self._repetition_count
+
+    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> Waveform:
+        return RepetitionWaveform.from_repetition_count(
+            body=self._body.unsafe_get_subset_for_channels(channels),
+            repetition_count=self._repetition_count)
+
+    def is_constant(self) -> bool:
+        return self._body.is_constant()
+
+    def constant_value(self, channel: ChannelID) -> Optional[float]:
+        return self._body.constant_value(channel)
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        return self._body.constant_value_dict()
+
+    def __repr__(self):
+        return f"{type(self).__name__}(body={self._body!r}, repetition_count={self._repetition_count!r})"
+
+
+class TransformingWaveform(Waveform):
+    __slots__ = ('_inner_waveform', '_transformation', '_cached_data', '_cached_times')
+
+    def __init__(self, inner_waveform: Waveform, transformation: Transformation):
+        """"""
+        super(TransformingWaveform, self).__init__(duration=inner_waveform.duration)
+        self._inner_waveform = inner_waveform
+        self._transformation = transformation
+
+        # cache data of inner channels based identified and invalidated by the sample times
+        self._cached_data = None
+        self._cached_times = lambda: None
+
+    @classmethod
+    def from_transformation(cls, inner_waveform: Waveform, transformation: Transformation) -> Waveform:
+        constant_values = inner_waveform.constant_value_dict()
+
+        if constant_values is None or not transformation.is_constant_invariant():
+            return cls(inner_waveform, transformation)
+
+        transformed_constant_values = {key: value for key, value in transformation(0., constant_values).items()}
+        return ConstantWaveform.from_mapping(inner_waveform.duration, transformed_constant_values)
+
+    def is_constant(self) -> bool:
+        # only true if `from_transformation` was used
+        return False
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        # only true if `from_transformation` was used
+        return None
+
+    def constant_value(self, channel: ChannelID) -> Optional[float]:
+        if not self._transformation.is_constant_invariant():
+            return None
+        in_channels = self._transformation.get_input_channels({channel})
+        in_values = {ch: self._inner_waveform.constant_value(ch) for ch in in_channels}
+        if any(val is None for val in in_values.values()):
+            return None
+        else:
+            return self._transformation(0., in_values)[channel]
+
+    @property
+    def inner_waveform(self) -> Waveform:
+        return self._inner_waveform
+
+    @property
+    def transformation(self) -> Transformation:
+        return self._transformation
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        return self.transformation.get_output_channels(self.inner_waveform.defined_channels)
+
+    @property
+    def compare_key(self) -> Tuple[Waveform, Transformation]:
+        return self.inner_waveform, self.transformation
+
+    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> 'SubsetWaveform':
+        return SubsetWaveform(self, channel_subset=channels)
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        if self._cached_times() is not sample_times:
+            self._cached_data = dict()
+            self._cached_times = ref(sample_times)
+
+        if channel not in self._cached_data:
+
+            inner_channels = self.transformation.get_input_channels({channel})
+
+            inner_data = {inner_channel: self.inner_waveform.unsafe_sample(inner_channel, sample_times)
+                          for inner_channel in inner_channels}
+
+            outer_data = self.transformation(sample_times, inner_data)
+            self._cached_data.update(outer_data)
+
+        if output_array is None:
+            output_array = self._cached_data[channel]
+        else:
+            output_array[:] = self._cached_data[channel]
+
+        return output_array
+
+
+class SubsetWaveform(Waveform):
+    __slots__ = ('_inner_waveform', '_channel_subset')
+
+    def __init__(self, inner_waveform: Waveform, channel_subset: Set[ChannelID]):
+        super().__init__(duration=inner_waveform.duration)
+        self._inner_waveform = inner_waveform
+        self._channel_subset = frozenset(channel_subset)
+
+    @property
+    def inner_waveform(self) -> Waveform:
+        return self._inner_waveform
+
+    @property
+    def defined_channels(self) -> FrozenSet[ChannelID]:
+        return self._channel_subset
+
+    @property
+    def compare_key(self) -> Tuple[frozenset, Waveform]:
+        return self.defined_channels, self.inner_waveform
+
+    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> Waveform:
+        return self.inner_waveform.get_subset_for_channels(channels)
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        return self.inner_waveform.unsafe_sample(channel, sample_times, output_array)
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        d = self._inner_waveform.constant_value_dict()
+        if d is not None:
+            return {ch: d[ch] for ch in self._channel_subset}
+
+    def constant_value(self, channel: ChannelID) -> Optional[float]:
+        if channel not in self._channel_subset:
+            raise KeyError(channel)
+        return self._inner_waveform.constant_value(channel)
+
+
+class ArithmeticWaveform(Waveform):
+    """Channels only present in one waveform have the operations neutral element on the other."""
+
+    numpy_operator_map = {'+': np.add,
+                          '-': np.subtract}
+    operator_map = {'+': operator.add,
+                    '-': operator.sub}
+
+    rhs_only_map = {'+': operator.pos,
+                    '-': operator.neg}
+    numpy_rhs_only_map = {'+': np.positive,
+                          '-': np.negative}
+
+    __slots__ = ('_lhs', '_rhs', '_arithmetic_operator')
+
+    def __init__(self,
+                 lhs: Waveform,
+                 arithmetic_operator: str,
+                 rhs: Waveform):
+        super().__init__(duration=lhs.duration)
+        self._lhs = lhs
+        self._rhs = rhs
+        self._arithmetic_operator = arithmetic_operator
+
+        assert np.isclose(float(self._lhs.duration), float(self._rhs.duration))
+        assert arithmetic_operator in self.operator_map
+
+    @classmethod
+    def from_operator(cls, lhs: Waveform, arithmetic_operator: str, rhs: Waveform):
+        # one could optimize rhs_cv to being only created if lhs_cv is not None but this makes the code harder to read
+        lhs_cv = lhs.constant_value_dict()
+        rhs_cv = rhs.constant_value_dict()
+        if lhs_cv is None or rhs_cv is None:
+            return cls(lhs, arithmetic_operator, rhs)
+
+        else:
+            constant_values = dict(lhs_cv)
+            op = cls.operator_map[arithmetic_operator]
+            rhs_op = cls.rhs_only_map[arithmetic_operator]
+
+            for ch, rhs_val in rhs_cv.items():
+                if ch in constant_values:
+                    constant_values[ch] = op(constant_values[ch], rhs_val)
+                else:
+                    constant_values[ch] = rhs_op(rhs_val)
+
+            duration = lhs.duration
+            assert isclose(duration, rhs.duration)
+
+            return ConstantWaveform.from_mapping(duration, constant_values)
+
+    def constant_value(self, channel: ChannelID) -> Optional[float]:
+        if channel not in self._rhs.defined_channels:
+            return self._lhs.constant_value(channel)
+        rhs = self._rhs.constant_value(channel)
+        if rhs is None:
+            return None
+
+        if channel in self._lhs.defined_channels:
+            lhs = self._lhs.constant_value(channel)
+            if lhs is None:
+                return None
+
+            return self.operator_map[self._arithmetic_operator](lhs, rhs)
+        else:
+            return self.rhs_only_map[self._arithmetic_operator](rhs)
+
+    def is_constant(self) -> bool:
+        # only correct if from_operator is used
+        return False
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        # only correct if from_operator is used
+        return None
+
+    @property
+    def lhs(self) -> Waveform:
+        return self._lhs
+
+    @property
+    def rhs(self) -> Waveform:
+        return self._rhs
+
+    @property
+    def arithmetic_operator(self) -> str:
+        return self._arithmetic_operator
+
+    @property
+    def duration(self) -> TimeType:
+        return self._lhs.duration
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        return self._lhs.defined_channels | self._rhs.defined_channels
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        if channel in self._lhs.defined_channels:
+            lhs = self._lhs.unsafe_sample(channel=channel, sample_times=sample_times, output_array=output_array)
+        else:
+            lhs = None
+
+        if channel in self._rhs.defined_channels:
+            rhs = self._rhs.unsafe_sample(channel=channel, sample_times=sample_times,
+                                          output_array=None if lhs is not None else output_array)
+        else:
+            rhs = None
+
+        if rhs is not None and lhs is not None:
+            arithmetic_operator = self.numpy_operator_map[self._arithmetic_operator]
+            if output_array is None:
+                output_array = lhs
+            return arithmetic_operator(lhs, rhs, out=output_array)
+
+        else:
+            if lhs is None:
+                assert rhs is not None, "channel %r not in defined channels (internal bug)" % channel
+                return self.numpy_rhs_only_map[self._arithmetic_operator](rhs, out=output_array)
+            else:
+                return lhs
+
+    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> Waveform:
+        # TODO: optimization possible
+        return SubsetWaveform(self, channels)
+
+    @property
+    def compare_key(self) -> Tuple[str, Waveform, Waveform]:
+        return self._arithmetic_operator, self._lhs, self._rhs
+
+
+class FunctorWaveform(Waveform):
+    # TODO: Use Protocol to enforce that it accepts second argument has the keyword out
+    Functor = callable
+
+    __slots__ = ('_inner_waveform', '_functor')
+
+    """Apply a channel wise functor that works inplace to all results. The functor must accept two arguments"""
+    def __init__(self, inner_waveform: Waveform, functor: Mapping[ChannelID, Functor]):
+        super(FunctorWaveform, self).__init__(duration=inner_waveform.duration)
+        self._inner_waveform = inner_waveform
+        self._functor = dict(functor.items())
+
+        assert set(functor.keys()) == inner_waveform.defined_channels, ("There is no default identity mapping (yet)."
+                                                                        "File an issue on github if you need it.")
+
+    @classmethod
+    def from_functor(cls, inner_waveform: Waveform, functor: Mapping[ChannelID, Functor]):
+        constant_values = inner_waveform.constant_value_dict()
+        if constant_values is None:
+            return FunctorWaveform(inner_waveform, functor)
+
+        funced_constant_values = {ch: functor[ch](val) for ch, val in constant_values.items()}
+        return ConstantWaveform.from_mapping(inner_waveform.duration, funced_constant_values)
+
+    def is_constant(self) -> bool:
+        # only correct if `from_functor` was used
+        return False
+
+    def constant_value_dict(self) -> Optional[Mapping[ChannelID, float]]:
+        # only correct if `from_functor` was used
+        return None
+
+    def constant_value(self, channel: ChannelID) -> Optional[float]:
+        inner = self._inner_waveform.constant_value(channel)
+        if inner is None:
+            return None
+        else:
+            return self._functor[channel](inner)
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        return self._inner_waveform.defined_channels
+
+    def unsafe_sample(self,
+                      channel: ChannelID,
+                      sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        inner_output = self._inner_waveform.unsafe_sample(channel, sample_times, output_array)
+        return self._functor[channel](inner_output, out=inner_output)
+
+    def unsafe_get_subset_for_channels(self, channels: Set[ChannelID]) -> Waveform:
+        return FunctorWaveform.from_functor(
+            self._inner_waveform.unsafe_get_subset_for_channels(channels),
+            {ch: self._functor[ch] for ch in channels})
+
+    @property
+    def compare_key(self) -> Tuple[Waveform, FrozenSet]:
+        return self._inner_waveform, frozenset(self._functor.items())
+
+
+class ReversedWaveform(Waveform):
+    """Reverses the inner waveform in time."""
+
+    __slots__ = ('_inner',)
+
+    def __init__(self, inner: Waveform):
+        super().__init__(duration=inner.duration)
+        self._inner = inner
+
+    @classmethod
+    def from_to_reverse(cls, inner: Waveform) -> Waveform:
+        if inner.constant_value_dict():
+            return inner
+        else:
+            return cls(inner)
+
+    def unsafe_sample(self, channel: ChannelID, sample_times: np.ndarray,
+                      output_array: Union[np.ndarray, None] = None) -> np.ndarray:
+        inner_sample_times = (float(self.duration) - sample_times)[::-1]
+        if output_array is None:
+            return self._inner.unsafe_sample(channel, inner_sample_times, None)[::-1]
+        else:
+            inner_output_array = output_array[::-1]
+            inner_output_array = self._inner.unsafe_sample(channel, inner_sample_times, output_array=inner_output_array)
+            if inner_output_array.base not in (output_array, output_array.base):
+                # TODO: is there a guarantee by numpy we never end up here?
+                output_array[:] = inner_output_array[::-1]
+            return output_array
+
+    @property
+    def defined_channels(self) -> AbstractSet[ChannelID]:
+        return self._inner.defined_channels
+
+    def unsafe_get_subset_for_channels(self, channels: AbstractSet[ChannelID]) -> 'Waveform':
+        return ReversedWaveform.from_to_reverse(self._inner.unsafe_get_subset_for_channels(channels))
+
+    @property
+    def compare_key(self) -> Hashable:
+        return self._inner.compare_key
+
+    def reversed(self) -> 'Waveform':
+        return self._inner
diff --git a/qupulse/pulses/arithmetic_pulse_template.py b/qupulse/pulses/arithmetic_pulse_template.py
index 7a2735ee2..eedfce20b 100644
--- a/qupulse/pulses/arithmetic_pulse_template.py
+++ b/qupulse/pulses/arithmetic_pulse_template.py
@@ -14,8 +14,8 @@
 from qupulse.utils.types import ChannelID
 from qupulse.pulses.measurement import MeasurementWindow
 from qupulse.pulses.pulse_template import AtomicPulseTemplate, PulseTemplate
-from qupulse._program.waveforms import Waveform, ArithmeticWaveform, TransformingWaveform
-from qupulse._program.transformation import Transformation, ScalingTransformation, OffsetTransformation,\
+from qupulse.program.waveforms import Waveform, ArithmeticWaveform, TransformingWaveform
+from qupulse.program.transformation import Transformation, ScalingTransformation, OffsetTransformation,\
     IdentityTransformation
 
 
@@ -197,7 +197,8 @@ def __init__(self,
                  arithmetic_operator: str,
                  rhs: Union[PulseTemplate, ExpressionLike, Mapping[ChannelID, ExpressionLike]],
                  *,
-                 identifier: Optional[str] = None):
+                 identifier: Optional[str] = None,
+                 registry: PulseRegistryType = None):
         """Implements the arithmetics between an aribrary pulse template and scalar values. The values can be the same
         for all channels, channel specific or only for a subset of the inner pulse templates defined channels.
         The expression may be time dependent if the pulse template is atomic.
@@ -263,6 +264,8 @@ def __init__(self,
             # this is a hack so we can use the AtomicPulseTemplate.integral default implementation
             self._AS_EXPRESSION_TIME = AtomicPulseTemplate._AS_EXPRESSION_TIME
 
+        self._register(registry=registry)
+
     @staticmethod
     def _parse_operand(operand: Union[ExpressionLike, Mapping[ChannelID, ExpressionLike]],
                        channels: Set[ChannelID]) -> Union[ExpressionScalar, Mapping[ChannelID, ExpressionScalar]]:
@@ -379,7 +382,7 @@ def _internal_create_program(self, *,
                                  channel_mapping: Dict[ChannelID, Optional[ChannelID]],
                                  global_transformation: Optional[Transformation],
                                  to_single_waveform: Set[Union[str, 'PulseTemplate']],
-                                 parent_loop: 'Loop'):
+                                 program_builder: 'ProgramBuilder'):
         """The operation is applied by modifying the transformation the pulse template operand sees."""
         if not scope.get_volatile_parameters().keys().isdisjoint(self._scalar_operand_parameters):
             raise NotImplementedError('The scalar operand of arithmetic pulse template cannot be volatile')
@@ -395,7 +398,7 @@ def _internal_create_program(self, *,
                                              channel_mapping=channel_mapping,
                                              global_transformation=transformation,
                                              to_single_waveform=to_single_waveform,
-                                             parent_loop=parent_loop)
+                                             program_builder=program_builder)
 
     def build_waveform(self,
                        parameters: Dict[str, Real],
diff --git a/qupulse/pulses/constant_pulse_template.py b/qupulse/pulses/constant_pulse_template.py
index 24ae16847..192ee82b1 100644
--- a/qupulse/pulses/constant_pulse_template.py
+++ b/qupulse/pulses/constant_pulse_template.py
@@ -10,7 +10,7 @@
 import numbers
 from typing import Any, Dict, List, Optional, Union, Mapping, AbstractSet
 
-from qupulse._program.waveforms import ConstantWaveform
+from qupulse.program.waveforms import ConstantWaveform
 from qupulse.utils.types import TimeType, ChannelID
 from qupulse.utils import cached_property
 from qupulse.expressions import ExpressionScalar, ExpressionLike
diff --git a/qupulse/pulses/function_pulse_template.py b/qupulse/pulses/function_pulse_template.py
index fa02feaff..24d98fbe2 100644
--- a/qupulse/pulses/function_pulse_template.py
+++ b/qupulse/pulses/function_pulse_template.py
@@ -17,7 +17,7 @@
 from qupulse.utils.types import ChannelID, TimeType, time_from_float
 from qupulse.pulses.parameters import ParameterConstrainer, ParameterConstraint
 from qupulse.pulses.pulse_template import AtomicPulseTemplate, MeasurementDeclaration
-from qupulse._program.waveforms import FunctionWaveform
+from qupulse.program.waveforms import FunctionWaveform
 
 
 __all__ = ["FunctionPulseTemplate"]
diff --git a/qupulse/pulses/loop_pulse_template.py b/qupulse/pulses/loop_pulse_template.py
index 101705ebc..0f458c687 100644
--- a/qupulse/pulses/loop_pulse_template.py
+++ b/qupulse/pulses/loop_pulse_template.py
@@ -1,5 +1,6 @@
 """This module defines LoopPulseTemplate, a higher-order hierarchical pulse template that loops
 another PulseTemplate based on a condition."""
+import dataclasses
 import functools
 import itertools
 from abc import ABC
@@ -13,13 +14,13 @@
 from qupulse.parameter_scope import Scope, MappedScope, DictScope
 from qupulse.utils.types import FrozenDict, FrozenMapping
 
-from qupulse._program._loop import Loop
+from qupulse.program import ProgramBuilder
 
 from qupulse.expressions import ExpressionScalar, ExpressionVariableMissingException, Expression
 from qupulse.utils import checked_int_cast, cached_property
 from qupulse.pulses.parameters import InvalidParameterNameException, ParameterConstrainer, ParameterNotProvidedException
 from qupulse.pulses.pulse_template import PulseTemplate, ChannelID, AtomicPulseTemplate
-from qupulse._program.waveforms import SequenceWaveform as ForLoopWaveform
+from qupulse.program.waveforms import SequenceWaveform as ForLoopWaveform
 from qupulse.pulses.measurement import MeasurementDefiner, MeasurementDeclaration
 from qupulse.pulses.range import ParametrizedRange, RangeScope
 
@@ -149,26 +150,22 @@ def _internal_create_program(self, *,
                                  channel_mapping: Dict[ChannelID, Optional[ChannelID]],
                                  global_transformation: Optional['Transformation'],
                                  to_single_waveform: Set[Union[str, 'PulseTemplate']],
-                                 parent_loop: Loop) -> None:
+                                 program_builder: ProgramBuilder) -> None:
         self.validate_scope(scope=scope)
 
-        try:
-            duration = self.duration.evaluate_in_scope(scope)
-        except ExpressionVariableMissingException as err:
-            raise ParameterNotProvidedException(err.variable) from err
+        loop_range = self._loop_range.to_range(scope)
+        loop_index_name = self._loop_index
 
-        if duration > 0:
-            measurements = self.get_measurement_windows(scope, measurement_mapping)
-            if measurements:
-                parent_loop.add_measurements(measurements)
+        measurements = self.get_measurement_windows(scope, measurement_mapping)
 
-            for local_scope in self._body_scope_generator(scope, forward=True):
-                self.body._create_program(scope=local_scope,
-                                          measurement_mapping=measurement_mapping,
-                                          channel_mapping=channel_mapping,
-                                          global_transformation=global_transformation,
-                                          to_single_waveform=to_single_waveform,
-                                          parent_loop=parent_loop)
+        for iteration_program_builder in program_builder.with_iteration(loop_index_name, loop_range,
+                                                                        measurements=measurements):
+            self.body._create_program(scope=iteration_program_builder.inner_scope(scope),
+                                      measurement_mapping=measurement_mapping,
+                                      channel_mapping=channel_mapping,
+                                      global_transformation=global_transformation,
+                                      to_single_waveform=to_single_waveform,
+                                      program_builder=iteration_program_builder)
 
     def build_waveform(self, parameter_scope: Scope) -> ForLoopWaveform:
         return ForLoopWaveform([self.body.build_waveform(local_scope)
@@ -255,3 +252,9 @@ def __str__(self) -> str:
 
 
 _ForLoopScope = RangeScope
+
+
+@dataclasses.dataclass
+class _ForLoopIndexValue:
+    name: str
+    rng: range
diff --git a/qupulse/pulses/mapping_pulse_template.py b/qupulse/pulses/mapping_pulse_template.py
index 9134da251..07e7d1024 100644
--- a/qupulse/pulses/mapping_pulse_template.py
+++ b/qupulse/pulses/mapping_pulse_template.py
@@ -1,4 +1,4 @@
-from typing import Optional, Set, Dict, Union, List, Any, Tuple
+from typing import Optional, Set, Dict, Union, List, Any, Tuple, Mapping
 import itertools
 import numbers
 import collections
@@ -8,8 +8,8 @@
 from qupulse.parameter_scope import Scope, MappedScope
 from qupulse.pulses.pulse_template import PulseTemplate, MappingTuple
 from qupulse.pulses.parameters import ParameterNotProvidedException, ParameterConstrainer
-from qupulse._program.waveforms import Waveform
-from qupulse._program._loop import Loop
+from qupulse.program.waveforms import Waveform
+from qupulse.program import ProgramBuilder
 from qupulse.serialization import Serializer, PulseRegistryType
 
 __all__ = [
@@ -113,7 +113,7 @@ def __init__(self, template: PulseTemplate, *,
             template = template.template
 
         self.__template: PulseTemplate = template
-        self.__parameter_mapping = FrozenDict(parameter_mapping)
+        self.__parameter_mapping: Mapping[str, Expression] = FrozenDict(parameter_mapping)
         self.__external_parameters = set(itertools.chain(*(expr.variables for expr in self.__parameter_mapping.values())))
         self.__external_parameters |= self.constrained_parameters
         self.__measurement_mapping = measurement_mapping
@@ -260,7 +260,7 @@ def map_parameter_values(self, parameters: Dict[str, numbers.Real],
             A new dictionary with mapped numeric values.
         """
         self._validate_parameters(parameters=parameters, volatile=volatile)
-        return {parameter: mapping_function.evaluate_numeric(**parameters)
+        return {parameter: mapping_function.evaluate_in_scope(parameters)
                 for parameter, mapping_function in self.__parameter_mapping.items()}
 
     def map_scope(self, scope: Scope) -> MappedScope:
@@ -301,7 +301,7 @@ def _internal_create_program(self, *,
                                  channel_mapping: Dict[ChannelID, Optional[ChannelID]],
                                  global_transformation: Optional['Transformation'],
                                  to_single_waveform: Set[Union[str, 'PulseTemplate']],
-                                 parent_loop: Loop) -> None:
+                                 program_builder: ProgramBuilder) -> None:
         self.validate_scope(scope)
 
         # parameters are validated in map_parameters() call, no need to do it here again explicitly
@@ -310,7 +310,7 @@ def _internal_create_program(self, *,
                                       channel_mapping=self.get_updated_channel_mapping(channel_mapping),
                                       global_transformation=global_transformation,
                                       to_single_waveform=to_single_waveform,
-                                      parent_loop=parent_loop)
+                                      program_builder=program_builder)
 
     def build_waveform(self,
                        parameters: Dict[str, numbers.Real],
diff --git a/qupulse/pulses/measurement.py b/qupulse/pulses/measurement.py
index c9d1f9ba2..2a12575f9 100644
--- a/qupulse/pulses/measurement.py
+++ b/qupulse/pulses/measurement.py
@@ -2,7 +2,7 @@
 from numbers import Real
 import itertools
 
-from qupulse.expressions import Expression
+from qupulse.expressions import Expression, ExpressionScalar
 from qupulse.utils.types import MeasurementWindow
 from qupulse.parameter_scope import Scope
 
@@ -15,8 +15,8 @@ def __init__(self, measurements: Optional[List[MeasurementDeclaration]]):
             self._measurement_windows = []
         else:
             self._measurement_windows = [(name,
-                                          begin if isinstance(begin, Expression) else Expression(begin),
-                                          length if isinstance(length, Expression) else Expression(length))
+                                          begin if isinstance(begin, Expression) else ExpressionScalar(begin),
+                                          length if isinstance(length, Expression) else ExpressionScalar(length))
                                          for name, begin, length in measurements]
         for _, _, length in self._measurement_windows:
             if (length < 0) is True:
@@ -73,8 +73,8 @@ def measurement_parameters(self) -> AbstractSet[str]:
     def measurement_declarations(self) -> List[MeasurementDeclaration]:
         """Return the measurements that are directly declared on `self`. Does _not_ visit eventual child objects."""
         return [(name,
-                 begin.original_expression,
-                 length.original_expression)
+                 begin,
+                 length)
                 for name, begin, length in self._measurement_windows]
 
     @property
diff --git a/qupulse/pulses/multi_channel_pulse_template.py b/qupulse/pulses/multi_channel_pulse_template.py
index beb12b68e..6b76bb49f 100644
--- a/qupulse/pulses/multi_channel_pulse_template.py
+++ b/qupulse/pulses/multi_channel_pulse_template.py
@@ -17,8 +17,8 @@
 from qupulse.utils import isclose
 from qupulse.utils.sympy import almost_equal, Sympifyable
 from qupulse.utils.types import ChannelID, TimeType
-from qupulse._program.waveforms import MultiChannelWaveform, Waveform, TransformingWaveform
-from qupulse._program.transformation import ParallelChannelTransformation, Transformation, chain_transformations
+from qupulse.program.waveforms import MultiChannelWaveform, Waveform, TransformingWaveform
+from qupulse.program.transformation import ParallelChannelTransformation, Transformation, chain_transformations
 from qupulse.pulses.pulse_template import PulseTemplate, AtomicPulseTemplate
 from qupulse.pulses.mapping_pulse_template import MappingPulseTemplate, MappingTuple
 from qupulse.pulses.parameters import ParameterConstrainer
@@ -145,7 +145,7 @@ def build_waveform(self, parameters: Dict[str, numbers.Real],
             waveform = MultiChannelWaveform.from_parallel(sub_waveforms)
 
         if self._duration:
-            expected_duration = self._duration.evaluate_numeric(**parameters)
+            expected_duration = self._duration.evaluate_in_scope(parameters)
 
             if not isclose(expected_duration, waveform.duration):
                 raise ValueError('The duration does not '
diff --git a/qupulse/pulses/parameters.py b/qupulse/pulses/parameters.py
index 51b20e05d..da381b053 100644
--- a/qupulse/pulses/parameters.py
+++ b/qupulse/pulses/parameters.py
@@ -12,6 +12,7 @@
 from qupulse.serialization import AnonymousSerializable
 from qupulse.expressions import Expression
 from qupulse.parameter_scope import Scope, ParameterNotProvidedException
+from qupulse.utils.sympy import sympify
 
 __all__ = ["ParameterNotProvidedException", "ParameterConstraintViolation", "ParameterConstraint"]
 
@@ -22,9 +23,9 @@ def __init__(self, relation: Union[str, sympy.Expr]):
         super().__init__()
         if isinstance(relation, str) and '==' in relation:
             # The '==' operator is interpreted by sympy as exactly, however we need a symbolical evaluation
-            self._expression = sympy.Eq(*sympy.sympify(relation.split('==')))
+            self._expression = sympy.Eq(*sympify(relation.split('==')))
         else:
-            self._expression = sympy.sympify(relation)
+            self._expression = sympify(relation)
         if not isinstance(self._expression, sympy.logic.boolalg.Boolean):
             raise ValueError('Constraint is not boolean')
         self._expression = Expression(self._expression)
diff --git a/qupulse/pulses/point_pulse_template.py b/qupulse/pulses/point_pulse_template.py
index b5c5cfa03..0075a98dc 100644
--- a/qupulse/pulses/point_pulse_template.py
+++ b/qupulse/pulses/point_pulse_template.py
@@ -9,7 +9,7 @@
 from qupulse.utils.sympy import IndexedBroadcast
 from qupulse.utils.types import ChannelID
 from qupulse.expressions import Expression, ExpressionScalar
-from qupulse._program.waveforms import TableWaveform, TableWaveformEntry
+from qupulse.program.waveforms import TableWaveform, TableWaveformEntry
 from qupulse.pulses.parameters import ParameterConstraint, ParameterConstrainer
 from qupulse.pulses.pulse_template import AtomicPulseTemplate, MeasurementDeclaration
 from qupulse.pulses.table_pulse_template import TableEntry, EntryInInit
@@ -26,8 +26,8 @@
 
 class PointPulseEntry(TableEntry):
     def instantiate(self, parameters: Dict[str, numbers.Real], num_channels: int) -> Sequence[PointWaveformEntry]:
-        t = self.t.evaluate_numeric(**parameters)
-        vs = self.v.evaluate_numeric(**parameters)
+        t = self.t.evaluate_in_scope(parameters)
+        vs = self.v.evaluate_in_scope(parameters)
 
         if isinstance(vs, numbers.Number):
             vs = (vs,) * num_channels
@@ -71,7 +71,7 @@ def build_waveform(self,
                for channel in self.defined_channels):
             return None
 
-        if self.duration.evaluate_numeric(**parameters) == 0:
+        if self.duration.evaluate_in_scope(parameters) == 0:
             return None
 
         mapped_channels = tuple(channel_mapping[c] for c in self._channels)
diff --git a/qupulse/pulses/pulse_template.py b/qupulse/pulses/pulse_template.py
index 46dd5294f..97dd5cda4 100644
--- a/qupulse/pulses/pulse_template.py
+++ b/qupulse/pulses/pulse_template.py
@@ -13,20 +13,24 @@
 import collections
 from numbers import Real, Number
 
+import numpy
 import sympy
 
 from qupulse.utils.types import ChannelID, DocStringABCMeta, FrozenDict
 from qupulse.utils import forced_hash
 from qupulse.serialization import Serializable
 from qupulse.expressions import ExpressionScalar, Expression, ExpressionLike
-from qupulse._program._loop import Loop, to_waveform
-from qupulse._program.transformation import Transformation, IdentityTransformation, ChainedTransformation, chain_transformations
+from qupulse.program.loop import Loop, to_waveform
+from qupulse.program.transformation import Transformation, IdentityTransformation, ChainedTransformation, chain_transformations
 
-from qupulse._program.waveforms import Waveform, TransformingWaveform
+from qupulse.program.waveforms import Waveform, TransformingWaveform
 from qupulse.pulses.measurement import MeasurementDefiner, MeasurementDeclaration
 from qupulse.parameter_scope import Scope, DictScope
 
-__all__ = ["PulseTemplate", "AtomicPulseTemplate", "DoubleParameterNameException", "MappingTuple"]
+from qupulse.program import ProgramBuilder, default_program_builder, Program
+
+__all__ = ["PulseTemplate", "AtomicPulseTemplate", "DoubleParameterNameException", "MappingTuple",
+           "UnknownVolatileParameter"]
 
 
 MappingTuple = Union[Tuple['PulseTemplate'],
@@ -49,6 +53,8 @@ class PulseTemplate(Serializable):
     """This is not stable"""
     _DEFAULT_FORMAT_SPEC = 'identifier'
 
+    _CAST_INT_TO_INT64 = True
+
     def __init__(self, *,
                  identifier: Optional[str]) -> None:
         super().__init__(identifier=identifier)
@@ -95,6 +101,10 @@ def __rmatmul__(self, other: MappingTuple) -> 'SequencePulseTemplate':
 
         return SequencePulseTemplate.concatenate(other, self)
 
+    def __pow__(self, power: ExpressionLike):
+        """This is a convenience wrapper for :func:`.with_repetition`."""
+        return self.with_repetition(power)
+
     @property
     @abstractmethod
     def integral(self) -> Dict[ChannelID, ExpressionScalar]:
@@ -116,7 +126,8 @@ def create_program(self, *,
                        channel_mapping: Optional[Mapping[ChannelID, Optional[ChannelID]]]=None,
                        global_transformation: Optional[Transformation]=None,
                        to_single_waveform: Set[Union[str, 'PulseTemplate']]=None,
-                       volatile: Set[str] = None) -> Optional['Loop']:
+                       volatile: Union[Set[str], str] = None,
+                       program_builder: ProgramBuilder = None) -> Optional[Program]:
         """Translates this PulseTemplate into a program Loop.
 
         The returned Loop represents the PulseTemplate with all parameter values instantiated provided as dictated by
@@ -131,6 +142,8 @@ def create_program(self, *,
             to_single_waveform: A set of pulse templates (or identifiers) which are directly translated to a
                 waveform. This might change how transformations are applied. TODO: clarify
             volatile: Everything in the final program that depends on these parameters is marked as volatile
+            program_builder: This program builder is used to build the return value. If `None` `default_program_builder`
+                is used.
         Returns:
              A Loop object corresponding to this PulseTemplate.
         """
@@ -144,6 +157,12 @@ def create_program(self, *,
             to_single_waveform = set()
         if volatile is None:
             volatile = set()
+        elif isinstance(volatile, str):
+            volatile = {volatile}
+        else:
+            volatile = set(volatile)
+        if program_builder is None:
+            program_builder = default_program_builder()
 
         # make sure all channels are mapped
         complete_channel_mapping = {channel: channel for channel in self.defined_channels}
@@ -161,13 +180,24 @@ def create_program(self, *,
             scope = parameters
         else:
             parameters = dict(parameters)
+            to_int = numpy.int64 if self._CAST_INT_TO_INT64 else lambda x: x
             for parameter_name, value in parameters.items():
-                if not isinstance(value, Number):
+                if type(value) is int:
+                    # numpy casts ints to int32 per default on windows
+                    # this can easily lead to overflows when times of the order of seconds
+                    # are represented with integers
+                    parameters[parameter_name] = to_int(value)
+
+                elif not isinstance(value, Number):
                     parameters[parameter_name] = Expression(value).evaluate_numeric()
 
             scope = DictScope(values=FrozenDict(parameters), volatile=volatile)
 
-        root_loop = Loop()
+        for volatile_name in scope.get_volatile_parameters():
+            if volatile_name not in scope:
+                warnings.warn(f"The volatile parameter {volatile_name!r} is not in the given parameters.",
+                              category=UnknownVolatileParameter,
+                              stacklevel=2)
 
         # call subclass specific implementation
         self._create_program(scope=scope,
@@ -175,11 +205,9 @@ def create_program(self, *,
                              channel_mapping=complete_channel_mapping,
                              global_transformation=global_transformation,
                              to_single_waveform=to_single_waveform,
-                             parent_loop=root_loop)
+                             program_builder=program_builder)
 
-        if root_loop.waveform is None and len(root_loop.children) == 0:
-            return None  # return None if no program
-        return root_loop
+        return program_builder.to_program()
 
     @abstractmethod
     def _internal_create_program(self, *,
@@ -188,7 +216,7 @@ def _internal_create_program(self, *,
                                  channel_mapping: Dict[ChannelID, Optional[ChannelID]],
                                  global_transformation: Optional[Transformation],
                                  to_single_waveform: Set[Union[str, 'PulseTemplate']],
-                                 parent_loop: Loop) -> None:
+                                 program_builder: ProgramBuilder) -> None:
         """The subclass specific implementation of create_program().
 
         Receives a Loop instance parent_loop to which it should append measurements and its own Loops as children.
@@ -209,36 +237,22 @@ def _create_program(self, *,
                         channel_mapping: Dict[ChannelID, Optional[ChannelID]],
                         global_transformation: Optional[Transformation],
                         to_single_waveform: Set[Union[str, 'PulseTemplate']],
-                        parent_loop: Loop):
+                        program_builder: ProgramBuilder):
         """Generic part of create program. This method handles to_single_waveform and the configuration of the
         transformer."""
         if self.identifier in to_single_waveform or self in to_single_waveform:
-            root = Loop()
-
-            if not scope.get_volatile_parameters().keys().isdisjoint(self.parameter_names):
-                raise NotImplementedError('A pulse template that has volatile parameters cannot be transformed into a '
-                                          'single waveform yet.')
-
-            self._internal_create_program(scope=scope,
-                                          measurement_mapping=measurement_mapping,
-                                          channel_mapping=channel_mapping,
-                                          global_transformation=None,
-                                          to_single_waveform=to_single_waveform,
-                                          parent_loop=root)
-
-            waveform = to_waveform(root)
-
-            if global_transformation:
-                waveform = TransformingWaveform.from_transformation(waveform, global_transformation)
+            with program_builder.new_subprogram(global_transformation=global_transformation) as inner_program_builder:
 
-            # convert the nicely formatted measurement windows back into the old format again :(
-            measurements = root.get_measurement_windows()
-            measurement_window_list = []
-            for measurement_name, (begins, lengths) in measurements.items():
-                measurement_window_list.extend(zip(itertools.repeat(measurement_name), begins, lengths))
+                if not scope.get_volatile_parameters().keys().isdisjoint(self.parameter_names):
+                    raise NotImplementedError('A pulse template that has volatile parameters cannot be transformed into a '
+                                              'single waveform yet.')
 
-            parent_loop.add_measurements(measurement_window_list)
-            parent_loop.append_child(waveform=waveform)
+                self._internal_create_program(scope=scope,
+                                              measurement_mapping=measurement_mapping,
+                                              channel_mapping=channel_mapping,
+                                              global_transformation=None,
+                                              to_single_waveform=to_single_waveform,
+                                              program_builder=inner_program_builder)
 
         else:
             self._internal_create_program(scope=scope,
@@ -246,7 +260,7 @@ def _create_program(self, *,
                                           channel_mapping=channel_mapping,
                                           to_single_waveform=to_single_waveform,
                                           global_transformation=global_transformation,
-                                          parent_loop=parent_loop)
+                                          program_builder=program_builder)
 
     def with_parallel_channels(self, values: Mapping[ChannelID, ExpressionLike]) -> 'PulseTemplate':
         """Create a new pulse template that sets the given channels to the corresponding values.
@@ -358,6 +372,47 @@ def with_appended(self, *appended: 'PulseTemplate'):
         else:
             return self
 
+    def pad_to(self, to_new_duration: Union[ExpressionLike, Callable[[Expression], ExpressionLike]],
+               pt_kwargs: Mapping[str, Any] = None) -> 'PulseTemplate':
+        """Pad this pulse template to the given duration.
+        The target duration can be numeric, symbolic or a callable that returns a new duration from the current
+        duration.
+
+        Examples:
+            # pad to a fixed duration
+            >>> padded_1 = my_pt.pad_to(1000)
+
+            # pad to a fixed sample coun
+            >>> padded_2 = my_pt.pad_to('sample_rate * 1000')
+
+            # pad to the next muliple of 16 samples with a symbolic sample rate
+            >>> padded_3 = my_pt.pad_to(to_next_multiple('sample_rate', 16))
+
+            # pad to the next muliple of 16 samples with a fixed sample rate of 1 GHz
+            >>> padded_4 = my_pt.pad_to(to_next_multiple(1, 16))
+        Args:
+            to_new_duration: Duration or callable that maps the current duration to the new duration
+            pt_kwargs: Keyword arguments for the newly created sequence pulse template.
+
+        Returns:
+            A pulse template that has the duration given by ``to_new_duration``. It can be ``self`` if the duration is
+            already as required. It is never ``self`` if ``pt_kwargs`` is non-empty.
+        """
+        from qupulse.pulses import ConstantPT, SequencePT
+        current_duration = self.duration
+        if callable(to_new_duration):
+            new_duration = to_new_duration(current_duration)
+        else:
+            new_duration = ExpressionScalar(to_new_duration)
+        pad_duration = new_duration - current_duration
+        if not pt_kwargs and pad_duration == 0:
+            return self
+        pad_pt = ConstantPT(pad_duration, self.final_values)
+        if pt_kwargs:
+            return SequencePT(self, pad_pt, **pt_kwargs)
+        else:
+            return self @ pad_pt
+
     def __format__(self, format_spec: str):
         if format_spec == '':
             format_spec = self._DEFAULT_FORMAT_SPEC
@@ -452,7 +507,7 @@ def _internal_create_program(self, *,
                                  channel_mapping: Dict[ChannelID, Optional[ChannelID]],
                                  global_transformation: Optional[Transformation],
                                  to_single_waveform: Set[Union[str, 'PulseTemplate']],
-                                 parent_loop: Loop) -> None:
+                                 program_builder: ProgramBuilder) -> None:
         """Parameter constraints are validated in build_waveform because build_waveform is guaranteed to be called
         during sequencing"""
         ### current behavior (same as previously): only adds EXEC Loop and measurements if a waveform exists.
@@ -464,12 +519,17 @@ def _internal_create_program(self, *,
         if waveform:
             measurements = self.get_measurement_windows(parameters=scope,
                                                         measurement_mapping=measurement_mapping)
+            program_builder.measure(measurements)
 
             if global_transformation:
                 waveform = TransformingWaveform.from_transformation(waveform, global_transformation)
 
-            parent_loop.add_measurements(measurements=measurements)
-            parent_loop.append_child(waveform=waveform)
+            constant_values = waveform.constant_value_dict()
+            if constant_values is None:
+                program_builder.play_arbitrary_waveform(waveform)
+            else:
+                program_builder.hold_voltage(waveform.duration, constant_values)
+
 
     @abstractmethod
     def build_waveform(self,
@@ -531,3 +591,6 @@ def __str__(self) -> str:
             self.templateA, self.templateB, ', '.join(self.names)
         )
 
+
+class UnknownVolatileParameter(RuntimeWarning):
+    pass
diff --git a/qupulse/pulses/repetition_pulse_template.py b/qupulse/pulses/repetition_pulse_template.py
index 809a91ce0..ead19c6d9 100644
--- a/qupulse/pulses/repetition_pulse_template.py
+++ b/qupulse/pulses/repetition_pulse_template.py
@@ -8,7 +8,8 @@
 import numpy as np
 
 from qupulse.serialization import Serializer, PulseRegistryType
-from qupulse._program._loop import Loop, VolatileRepetitionCount
+from qupulse.program.volatile import VolatileRepetitionCount
+from qupulse.program import ProgramBuilder
 from qupulse.parameter_scope import Scope
 
 from qupulse.utils.types import ChannelID
@@ -118,13 +119,11 @@ def _internal_create_program(self, *,
                                  channel_mapping: Dict[ChannelID, Optional[ChannelID]],
                                  global_transformation: Optional['Transformation'],
                                  to_single_waveform: AbstractSet[Union[str, 'PulseTemplate']],
-                                 parent_loop: Loop) -> None:
+                                 program_builder: ProgramBuilder) -> None:
         self.validate_scope(scope)
 
         repetition_count = max(0, self.get_repetition_count_value(scope))
 
-        # todo (2018-07-19): could in some circumstances possibly just multiply subprogram repetition count?
-        # could be tricky if any repetition count is volatile ? check later and optimize if necessary
         if repetition_count > 0:
             if scope.get_volatile_parameters().keys() & self.repetition_count.variables:
                 repetition_definition = VolatileRepetitionCount(self.repetition_count, scope)
@@ -132,19 +131,16 @@ def _internal_create_program(self, *,
             else:
                 repetition_definition = repetition_count
 
-            repj_loop = Loop(repetition_count=repetition_definition)
-            self.body._create_program(scope=scope,
-                                      measurement_mapping=measurement_mapping,
-                                      channel_mapping=channel_mapping,
-                                      global_transformation=global_transformation,
-                                      to_single_waveform=to_single_waveform,
-                                      parent_loop=repj_loop)
-            if repj_loop.waveform is not None or len(repj_loop.children) > 0:
-                measurements = self.get_measurement_windows(scope, measurement_mapping)
-                if measurements:
-                    parent_loop.add_measurements(measurements)
-
-                parent_loop.append_child(loop=repj_loop)
+            measurements = self.get_measurement_windows(scope, measurement_mapping)
+
+            for repetition_program_builder in program_builder.with_repetition(repetition_definition,
+                                                                              measurements=measurements):
+                self.body._create_program(scope=repetition_program_builder.inner_scope(scope),
+                                          measurement_mapping=measurement_mapping,
+                                          channel_mapping=channel_mapping,
+                                          global_transformation=global_transformation,
+                                          to_single_waveform=to_single_waveform,
+                                          program_builder=repetition_program_builder)
 
     def get_serialization_data(self, serializer: Optional[Serializer]=None) -> Dict[str, Any]:
         data = super().get_serialization_data(serializer)
diff --git a/qupulse/pulses/sequence_pulse_template.py b/qupulse/pulses/sequence_pulse_template.py
index 1ef1bed0b..5107bb104 100644
--- a/qupulse/pulses/sequence_pulse_template.py
+++ b/qupulse/pulses/sequence_pulse_template.py
@@ -8,14 +8,14 @@
 import warnings
 
 from qupulse.serialization import Serializer, PulseRegistryType
-from qupulse._program._loop import Loop
+from qupulse.program import ProgramBuilder
 from qupulse.parameter_scope import Scope
 from qupulse.utils import cached_property
 from qupulse.utils.types import MeasurementWindow, ChannelID, TimeType
 from qupulse.pulses.pulse_template import PulseTemplate, AtomicPulseTemplate
 from qupulse.pulses.parameters import ConstraintLike, ParameterConstrainer
 from qupulse.pulses.mapping_pulse_template import MappingPulseTemplate, MappingTuple
-from qupulse._program.waveforms import SequenceWaveform
+from qupulse.program.waveforms import SequenceWaveform
 from qupulse.pulses.measurement import MeasurementDeclaration, MeasurementDefiner
 from qupulse.expressions import Expression, ExpressionScalar
 
@@ -133,21 +133,18 @@ def _internal_create_program(self, *,
                                  channel_mapping: Dict[ChannelID, Optional[ChannelID]],
                                  global_transformation: Optional['Transformation'],
                                  to_single_waveform: Set[Union[str, 'PulseTemplate']],
-                                 parent_loop: Loop) -> None:
+                                 program_builder: ProgramBuilder) -> None:
         self.validate_scope(scope)
 
-        if self.duration.evaluate_in_scope(scope) > 0:
-            measurements = self.get_measurement_windows(scope, measurement_mapping)
-            if measurements:
-                parent_loop.add_measurements(measurements)
-
+        measurements = self.get_measurement_windows(scope, measurement_mapping)
+        with program_builder.with_sequence(measurements=measurements) as sequence_program_builder:
             for subtemplate in self.subtemplates:
                 subtemplate._create_program(scope=scope,
                                             measurement_mapping=measurement_mapping,
                                             channel_mapping=channel_mapping,
                                             global_transformation=global_transformation,
                                             to_single_waveform=to_single_waveform,
-                                            parent_loop=parent_loop)
+                                            program_builder=sequence_program_builder)
 
     def get_serialization_data(self, serializer: Optional[Serializer]=None) -> Dict[str, Any]:
         data = super().get_serialization_data(serializer)
diff --git a/qupulse/pulses/table_pulse_template.py b/qupulse/pulses/table_pulse_template.py
index 873bbdf93..f8b631add 100644
--- a/qupulse/pulses/table_pulse_template.py
+++ b/qupulse/pulses/table_pulse_template.py
@@ -23,7 +23,7 @@
 from qupulse.pulses.pulse_template import AtomicPulseTemplate, MeasurementDeclaration
 from qupulse.pulses.interpolation import InterpolationStrategy, LinearInterpolationStrategy, \
     HoldInterpolationStrategy, JumpInterpolationStrategy
-from qupulse._program.waveforms import TableWaveform, TableWaveformEntry
+from qupulse.program.waveforms import TableWaveform, TableWaveformEntry
 from qupulse.expressions import ExpressionScalar, Expression
 from qupulse.pulses.multi_channel_pulse_template import MultiChannelWaveform
 
@@ -259,6 +259,9 @@ def get_entries_instantiated(self, parameters: Dict[str, numbers.Real]) \
 
         duration = max(instantiated[-1].t for instantiated in instantiated_entries.values())
 
+        if duration == 0:
+            return {}
+
         # ensure that all channels have equal duration
         for channel, instantiated in instantiated_entries.items():
             final_entry = instantiated[-1]
@@ -321,9 +324,6 @@ def build_waveform(self,
         if not instantiated:
             return None
 
-        if self.duration.evaluate_numeric(**parameters) == 0:
-            return None
-
         waveforms = [TableWaveform.from_table(*ch_instantiated)
                      for ch_instantiated in instantiated]
 
diff --git a/qupulse/pulses/time_reversal_pulse_template.py b/qupulse/pulses/time_reversal_pulse_template.py
index a4758d1a7..35a1884be 100644
--- a/qupulse/pulses/time_reversal_pulse_template.py
+++ b/qupulse/pulses/time_reversal_pulse_template.py
@@ -1,8 +1,8 @@
 from typing import Optional, Set, Dict, Union
 
 from qupulse import ChannelID
-from qupulse._program._loop import Loop
-from qupulse._program.waveforms import Waveform
+from qupulse.program.loop import Loop
+from qupulse.program.waveforms import Waveform
 from qupulse.serialization import PulseRegistryType
 from qupulse.expressions import ExpressionScalar
 
diff --git a/qupulse/serialization.py b/qupulse/serialization.py
index 3825057ed..389748666 100644
--- a/qupulse/serialization.py
+++ b/qupulse/serialization.py
@@ -1024,7 +1024,11 @@ def filter_serializables(self, obj_dict) -> Any:
                 if get_default_pulse_registry() is self.storage:
                     registry = dict()
 
-                return deserialization_callback(identifier=obj_identifier, registry=registry, **obj_dict)
+                try:
+                    return deserialization_callback(identifier=obj_identifier, registry=registry, **obj_dict)
+                except Exception as err:
+                    raise ValueError(f"Unable to deserialize {type_identifier} from {obj_dict}",
+                                     type_identifier, obj_dict) from err
         return obj_dict
 
 
diff --git a/qupulse/utils/__init__.py b/qupulse/utils/__init__.py
index 43de28c1c..326072f4b 100644
--- a/qupulse/utils/__init__.py
+++ b/qupulse/utils/__init__.py
@@ -1,11 +1,12 @@
 """This package contains utility functions and classes as well as custom sympy extensions(hacks)."""
 
-from typing import Union, Iterable, Any, Tuple, Mapping, Iterator, TypeVar, Sequence, AbstractSet
+from typing import Union, Iterable, Any, Tuple, Mapping, Iterator, TypeVar, Sequence, AbstractSet, Optional, Callable
 import itertools
 import re
 import numbers
 from collections import OrderedDict
 from frozendict import frozendict
+from qupulse.expressions import ExpressionScalar, ExpressionLike
 
 import numpy
 
@@ -25,7 +26,7 @@
 
 
 __all__ = ["checked_int_cast", "is_integer", "isclose", "pairwise", "replace_multiple", "cached_property",
-           "forced_hash"]
+           "forced_hash", "to_next_multiple"]
 
 
 def checked_int_cast(x: Union[float, int, numpy.ndarray], epsilon: float=1e-6) -> int:
@@ -122,3 +123,30 @@ def forced_hash(obj) -> int:
             return hash(tuple(map(forced_hash, obj)))
 
         raise
+
+
+def to_next_multiple(sample_rate: ExpressionLike, quantum: int,
+                     min_quanta: Optional[int] = None) -> Callable[[ExpressionLike],ExpressionScalar]:
+    """Construct a helper function to expand a duration to one corresponding to
+    valid sample multiples according to the arguments given.
+    Useful e.g. for PulseTemplate.pad_to's 'to_new_duration'-argument.
+
+    Args:
+        sample_rate: sample rate with respect to which the duration is evaluated.
+        quantum: number of samples to whose next integer multiple the duration shall be rounded up to.
+        min_quanta: number of multiples of quantum not to fall short of.
+    Returns:
+        A function that takes a duration (ExpressionLike) as input, and returns
+        a duration rounded up to the next valid samples count in given sample rate.
+        The function returns 0 if duration==0, <0 is not checked if min_quanta is None.
+
+    """
+    sample_rate = ExpressionScalar(sample_rate)
+    #is it more efficient to omit the Max call if not necessary?
+    if min_quanta is None:
+        #double negative for ceil division.
+        return lambda duration: -(-(duration*sample_rate)//quantum) * (quantum/sample_rate)
+    else:
+        #still return 0 if duration==0
+        return lambda duration: ExpressionScalar(f'{quantum}/({sample_rate})*Max({min_quanta},-(-{duration}*{sample_rate}//{quantum}))*Max(0, sign({duration}))')
+   
\ No newline at end of file
diff --git a/qupulse/utils/numeric.py b/qupulse/utils/numeric.py
index b7b505f9d..5a263a39a 100644
--- a/qupulse/utils/numeric.py
+++ b/qupulse/utils/numeric.py
@@ -7,9 +7,16 @@
 import sympy
 
 
-def lcm(a: int, b: int):
-    """least common multiple"""
-    return a * b // gcd(a, b)
+try:
+    from math import lcm
+except ImportError:
+    # python version < 3.9
+    def lcm(*integers: int):
+        """Re-implementation of the least common multiple function that is in the standard library since python 3.9"""
+        result = 1
+        for value in integers:
+            result = result * value // gcd(value, result)
+        return result
 
 
 def smallest_factor_ge(n: int, min_factor: int, brute_force: int = 5):
diff --git a/qupulse/utils/performance.py b/qupulse/utils/performance.py
index 4076b664c..ebdb3b0a8 100644
--- a/qupulse/utils/performance.py
+++ b/qupulse/utils/performance.py
@@ -1,3 +1,4 @@
+import warnings
 from typing import Tuple
 import numpy as np
 
@@ -24,6 +25,76 @@ def _is_monotonic_numpy(arr: np.ndarray) -> bool:
     return np.all(arr[1:] >= arr[:-1])
 
 
+def _shrink_overlapping_windows_numpy(begins, lengths) -> bool:
+    supported_dtypes = ('int64', 'uint64')
+    if begins.dtype.name not in supported_dtypes or lengths.dtype.name not in supported_dtypes:
+        raise NotImplementedError("This function only supports 64 bit integer types yet.")
+
+    ends = begins + lengths
+
+    overlaps = np.zeros_like(ends, dtype=np.int64)
+    np.maximum(ends[:-1].view(np.int64) - begins[1:].view(np.int64), 0, out=overlaps[1:])
+
+    if np.any(overlaps >= lengths):
+        raise ValueError("Overlap is bigger than measurement window")
+    if np.any(overlaps > 0):
+        begins += overlaps.view(begins.dtype)
+        lengths -= overlaps.view(lengths.dtype)
+        return True
+    return False
+
+
+@njit
+def _shrink_overlapping_windows_numba(begins, lengths) -> bool:
+    shrank = False
+    for idx in range(len(begins) - 1):
+        end = begins[idx] + lengths[idx]
+        next_begin = begins[idx + 1]
+
+        if end > next_begin:
+            overlap = end - next_begin
+            shrank = True
+            if lengths[idx + 1] > overlap:
+                begins[idx + 1] += overlap
+                lengths[idx + 1] -= overlap
+            else:
+                raise ValueError("Overlap is bigger than measurement window")
+    return shrank
+
+
+class WindowOverlapWarning(RuntimeWarning):
+    COMMENT = (" This warning is an error by default. "
+               "Call 'warnings.simplefilter(WindowOverlapWarning, \"always\")' "
+               "to demote it to a regular warning.")
+
+    def __str__(self):
+        return super().__str__() + self.COMMENT
+
+
+warnings.simplefilter(category=WindowOverlapWarning, action='error')
+
+
+def shrink_overlapping_windows(begins, lengths, use_numba: bool = numba is not None) -> Tuple[np.array, np.array]:
+    """Shrink windows in place if they overlap. Emits WindowOverlapWarning if a window was shrunk.
+
+    Raises:
+        ValueError: if the overlap is bigger than a window.
+
+    Warnings:
+        WindowOverlapWarning
+    """
+    if use_numba:
+        backend = _shrink_overlapping_windows_numba
+    else:
+        backend = _shrink_overlapping_windows_numpy
+    begins = begins.copy()
+    lengths = lengths.copy()
+    if backend(begins, lengths):
+        warnings.warn("Found overlapping measurement windows which can be automatically shrunken if possible.",
+                      category=WindowOverlapWarning)
+    return begins, lengths
+
+
 @njit
 def _time_windows_to_samples_sorted_numba(begins, lengths,
                                           sample_rate: float) -> Tuple[np.ndarray, np.ndarray]:
@@ -81,4 +152,94 @@ def time_windows_to_samples(begins: np.ndarray, lengths: np.ndarray,
     is_monotonic = _is_monotonic_numba
 
 
+@njit
+def _average_windows_numba(time: np.ndarray, values: np.ndarray,
+                           begins: np.ndarray, ends: np.ndarray) -> np.ndarray:
+    n_samples, = time.shape
+    n_windows, = begins.shape
+
+    assert len(begins) == len(ends)
+    assert values.shape[0] == n_samples
+
+    result = np.zeros(begins.shape + values.shape[1:], dtype=float)
+    count = np.zeros(n_windows, dtype=np.uint64)
+
+    start = 0
+    for i in range(n_samples):
+        t = time[i]
+        v = values[i, ...]
+
+        while start < n_windows and ends[start] <= t:
+            n = count[start]
+            if n == 0:
+                result[start] = np.nan
+            else:
+                result[start] /= n
+            start += 1
+
+        idx = start
+        while idx < n_windows and begins[idx] <= t:
+            result[idx] += v
+            count[idx] += 1
+            idx += 1
+
+    for idx in range(start, n_windows):
+        n = count[idx]
+        if n == 0:
+            result[idx] = np.nan
+        else:
+            result[idx] /= count[idx]
+
+    return result
+
+
+def _average_windows_numpy(time: np.ndarray, values: np.ndarray,
+                           begins: np.ndarray, ends: np.ndarray) -> np.ndarray:
+    start = np.searchsorted(time, begins)
+    end = np.searchsorted(time, ends)
+
+    val_shape = values.shape[1:]
+
+    count = end - start
+    val_mask = result_mask = start < end
+
+    result = np.zeros(begins.shape + val_shape, dtype=float)
+    while np.any(val_mask):
+        result[val_mask, ...] += values[start[val_mask], ...]
+        start[val_mask] += 1
+        val_mask = start < end
+
+    result[~result_mask, ...] = np.nan
+    if result.ndim == 1:
+        result[result_mask, ...] /= count[result_mask]
+    else:
+        result[result_mask, ...] /= count[result_mask, None]
+
+    return result
 
+
+def average_windows(time: np.ndarray, values: np.ndarray, begins: np.ndarray, ends: np.ndarray):
+    """This function calculates the average over all windows that are defined by begins and ends.
+    The function assumes that the given time array is monotonically increasing and might produce
+    nonsensical results if not.
+
+    Args:
+        time: Time associated with the values of shape (n_samples,)
+        values: Values to average of shape (n_samples,) or (n_samples, n_channels)
+        begins: Beginning time stamps of the windows of shape (n_windows,)
+        ends: Ending time stamps of the windows of shape (n_windows,)
+
+    Returns:
+        Averaged values for each window of shape (n_windows,) or (n_windows, n_channels).
+        Windows without samples are NaN.
+    """
+    n_samples, = time.shape
+    n_windows, = begins.shape
+
+    assert n_windows == len(ends)
+    assert values.shape[0] == n_samples
+
+    if numba is None:
+        return _average_windows_numpy(time, values, begins, ends)
+    else:
+        return _average_windows_numba(time, values, begins, ends)
diff --git a/qupulse/utils/sympy.py b/qupulse/utils/sympy.py
index b4d7286f2..7a04d53f6 100644
--- a/qupulse/utils/sympy.py
+++ b/qupulse/utils/sympy.py
@@ -22,7 +22,7 @@
 except ImportError:
     _special_functions = {fname: numpy.vectorize(fobject)
                           for fname, fobject in math.__dict__.items()
-                          if not fname.startswith('_') and fname not in numpy.__dict__}
+                          if callable(fobject) and not fname.startswith('_') and fname not in numpy.__dict__}
     warnings.warn('scipy is not installed. This reduces the set of available functions to those present in numpy + '
                   'manually vectorized functions in math.')
 
@@ -363,7 +363,7 @@ def recursive_substitution(expression: sympy.Expr,
     return _recursive_substitution(expression, substitutions)
 
 
-_base_environment = {'builtins': builtins, '__builtins__':  builtins}
+_base_environment = {'builtins': builtins, '__builtins__':  builtins, 'math': math}
 _math_environment = {**_base_environment, **math.__dict__}
 _numpy_environment = {**_base_environment, **numpy.__dict__}
 _sympy_environment = {**_base_environment, **sympy.__dict__}
diff --git a/setup.cfg b/setup.cfg
index 68647e6f9..a77b1e909 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -21,11 +21,12 @@ packages = find:
 package_dir =
     qupulse=qupulse
     qctoolkit=qctoolkit
-python_requires = >=3.7
+python_requires = >=3.8
 install_requires =
     sympy>=1.1.1
     numpy
     cached_property;python_version<'3.8'
+    typing-extensions;python_version<'3.8'
     frozendict
     lazy_loader
 test_suite = tests
@@ -42,7 +43,9 @@ docs =
 plotting = matplotlib
 tabor-instruments =
     tabor_control>=0.1.1
-zurich-instruments = zhinst
+zurich-instruments =
+    qupulse-hdawg-legacy;python_version<'3.9'
+    qupulse-hdawg;python_version>='3.9'
 Faster-fractions = gmpy2
 tektronix = tek_awg>=0.2.1
 autologging = autologging
@@ -52,15 +55,7 @@ autologging = autologging
 faster-sampling = numba
 # Everything besides awg drivers
 default =
-    pytest
-    pytest_benchmark
-    sphinx>=4
-    ipykernel
-    pyvisa
-    matplotlib
-    gmpy2
-    autologging
-    numba
+    qupulse[tests,docs,plotting,Faster-fractions,autologging,faster-sampling]
     pandas
 
 [options.packages.find]
@@ -88,8 +83,8 @@ filterwarnings =
 
 [build_sphinx]
 project = 'qupulse'
-version = 0.5
-release = 0.5rc
+version = 0.9
+release = 0.9
 source-dir = ./doc/source
 build-dir = ./doc/build
 fresh-env = 1


Test patch:
diff --git a/.github/workflows/pythontest.yaml b/.github/workflows/pythontest.yaml
index cacee5647..64a7c27cd 100644
--- a/.github/workflows/pythontest.yaml
+++ b/.github/workflows/pythontest.yaml
@@ -22,17 +22,16 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        python-version: ["3.7", "3.8", "3.9"]
+        python-version: ["3.8", "3.9", "3.10"]
         time-type: ["fractions", "gmpy2"]
     env:
       INSTALL_EXTRAS: tests,plotting,zurich-instruments,tektronix,tabor-instruments
 
 
     steps:
-    - name: Prepare gmpy2 build dependencies
+    - name: Add gmpy extra feature
       if: ${{ matrix.time-type }} == 'gmpy2'
       run: |
-        sudo apt-get install -y libgmp-dev libmpfr-dev libmpc-dev
         echo "INSTALL_EXTRAS=${{ env.INSTALL_EXTRAS }},Faster-fractions" >> $GITHUB_ENV
     
     - name: Checkout repository
diff --git a/tests/_program/loop_tests.py b/tests/_program/loop_tests.py
index 3bd811cd6..55d4e465f 100644
--- a/tests/_program/loop_tests.py
+++ b/tests/_program/loop_tests.py
@@ -11,12 +11,11 @@
 from qupulse.parameter_scope import DictScope
 
 from qupulse.utils.types import TimeType, time_from_float
-from qupulse._program.volatile import VolatileRepetitionCount
-from qupulse._program._loop import Loop, _make_compatible, _is_compatible, _CompatibilityLevel,\
+from qupulse.program.volatile import VolatileRepetitionCount
+from qupulse.program.loop import Loop, _make_compatible, _is_compatible, _CompatibilityLevel,\
     RepetitionWaveform, SequenceWaveform, make_compatible, MakeCompatibleWarning, DroppedMeasurementWarning,\
     VolatileModificationWarning, roll_constant_waveforms
-from qupulse._program._loop import Loop, _make_compatible, _is_compatible, _CompatibilityLevel,\
-    RepetitionWaveform, SequenceWaveform, make_compatible, MakeCompatibleWarning, ConstantWaveform
+from qupulse.program.waveforms import *
 from tests.pulses.sequencing_dummies import DummyWaveform
 from qupulse.pulses.multi_channel_pulse_template import MultiChannelWaveform
 
@@ -142,7 +141,7 @@ def test_get_measurement_windows(self):
         # no measurements left
         self.assertEqual({}, prog.get_measurement_windows())
 
-    def test_repr(self):
+    def test_str(self):
         wf_gen = WaveformGenerator(num_channels=1)
         wfs = [wf_gen() for _ in range(11)]
 
@@ -154,10 +153,19 @@ def test_repr(self):
                 loop.waveform = wfs.pop(0)
         self.assertEqual(len(wfs), 0)
 
-        self.assertEqual(repr(tree), expected)
+        self.assertEqual(str(tree), expected)
 
         with mock.patch.object(Loop, 'MAX_REPR_SIZE', 1):
-            self.assertEqual(repr(tree), '...')
+            self.assertEqual(str(tree), '...')
+
+    def test_repr(self):
+        root_loop = self.get_test_loop()
+
+        root_repr = repr(root_loop)
+
+        root_eval = eval(root_repr)
+
+        self.assertEqual(root_loop, root_eval)
 
     def test_is_leaf(self):
         root_loop = self.get_test_loop(waveform_generator=WaveformGenerator(1))
@@ -195,12 +203,12 @@ def test_flatten_and_balance(self):
         after = before.copy_tree_structure()
         after.flatten_and_balance(2)
 
-        wf_reprs = dict(zip(ascii_uppercase,
-                            (repr(loop.waveform)
+        wf_strs = dict(zip(ascii_uppercase,
+                            (str(loop.waveform)
                              for loop in before.get_depth_first_iterator()
                              if loop.is_leaf())))
 
-        before_repr = """\
+        before_str = """\
 LOOP 1 times:
   ->EXEC {A} 1 times
   ->LOOP 10 times:
@@ -220,10 +228,10 @@ def test_flatten_and_balance(self):
           ->EXEC {I} 8 times
       ->LOOP 9 times:
           ->EXEC {J} 10 times
-          ->EXEC {K} 11 times""".format(**wf_reprs)
-        self.assertEqual(repr(before), before_repr)
+          ->EXEC {K} 11 times""".format(**wf_strs)
+        self.assertEqual(str(before), before_str)
 
-        expected_after_repr = """\
+        expected_after_str = """\
 LOOP 1 times:
   ->LOOP 1 times:
       ->EXEC {A} 1 times
@@ -261,9 +269,9 @@ def test_flatten_and_balance(self):
       ->EXEC {I} 8 times
   ->LOOP 9 times:
       ->EXEC {J} 10 times
-      ->EXEC {K} 11 times""".format(**wf_reprs)
+      ->EXEC {K} 11 times""".format(**wf_strs)
 
-        self.assertEqual(expected_after_repr, repr(after))
+        self.assertEqual(expected_after_str, str(after))
 
     def test_flatten_and_balance_comparison_based(self):
         wfs = [DummyWaveform(duration=i) for i in range(2)]
@@ -479,27 +487,27 @@ def test_make_compatible(self):
                           sample_rate=TimeType.from_float(1.))
         priv_kwargs = dict(min_len=5, quantum=10, sample_rate=TimeType.from_float(1.))
 
-        with mock.patch('qupulse._program._loop._is_compatible',
+        with mock.patch('qupulse.program.loop._is_compatible',
                         return_value=_CompatibilityLevel.incompatible_too_short) as mocked:
             with self.assertRaisesRegex(ValueError, 'too short'):
                 make_compatible(program, **pub_kwargs)
             mocked.assert_called_once_with(program, **priv_kwargs)
 
-        with mock.patch('qupulse._program._loop._is_compatible',
+        with mock.patch('qupulse.program.loop._is_compatible',
                         return_value=_CompatibilityLevel.incompatible_fraction) as mocked:
             with self.assertRaisesRegex(ValueError, 'not an integer'):
                 make_compatible(program, **pub_kwargs)
             mocked.assert_called_once_with(program, **priv_kwargs)
 
-        with mock.patch('qupulse._program._loop._is_compatible',
+        with mock.patch('qupulse.program.loop._is_compatible',
                         return_value=_CompatibilityLevel.incompatible_quantum) as mocked:
             with self.assertRaisesRegex(ValueError, 'not a multiple of quantum'):
                 make_compatible(program, **pub_kwargs)
             mocked.assert_called_once_with(program, **priv_kwargs)
 
-        with mock.patch('qupulse._program._loop._is_compatible',
+        with mock.patch('qupulse.program.loop._is_compatible',
                         return_value=_CompatibilityLevel.action_required) as is_compat:
-            with mock.patch('qupulse._program._loop._make_compatible') as make_compat:
+            with mock.patch('qupulse.program.loop._make_compatible') as make_compat:
                 make_compatible(program, **pub_kwargs)
 
                 is_compat.assert_called_once_with(program, **priv_kwargs)
diff --git a/tests/_program/seqc_tests.py b/tests/_program/seqc_tests.py
deleted file mode 100644
index 6b2cfb0db..000000000
--- a/tests/_program/seqc_tests.py
+++ /dev/null
@@ -1,1067 +0,0 @@
-import unittest
-from unittest import TestCase, mock
-import time
-from itertools import zip_longest, islice
-import sys
-import tempfile
-import pathlib
-import hashlib
-import random
-
-import numpy as np
-
-from qupulse.expressions import ExpressionScalar
-from qupulse.parameter_scope import DictScope
-
-from qupulse._program._loop import Loop
-from qupulse._program.waveforms import ConstantWaveform
-from qupulse._program.seqc import BinaryWaveform, loop_to_seqc, WaveformPlayback, Repeat, SteppingRepeat, Scope,\
-    to_node_clusters, find_sharable_waveforms, mark_sharable_waveforms, UserRegisterManager, HDAWGProgramManager,\
-    UserRegister, WaveformFileSystem
-from qupulse._program.volatile import VolatileRepetitionCount
-
-from tests.pulses.sequencing_dummies import DummyWaveform
-
-try:
-    import zhinst
-except ImportError:
-    zhinst = None
-
-
-def take(n, iterable):
-    "Return first n items of the iterable as a list"
-    return list(islice(iterable, n))
-
-
-def dummy_loop_to_seqc(loop, **kwargs):
-    return loop
-
-
-class BinaryWaveformTest(unittest.TestCase):
-    MAX_RATE = 14
-
-    def test_dynamic_rate_reduction(self):
-
-        ones = np.ones(2**(self.MAX_RATE + 2) * 3, np.uint16)
-
-        for n in (2, 3, 5):
-            self.assertEqual(BinaryWaveform(ones[:n * 16 * 3]).dynamic_rate(), 0, f"Reducing {n}")
-        for n in (4, 6):
-            self.assertEqual(BinaryWaveform(ones[:16 * n * 3]).dynamic_rate(), 1)
-
-        irreducibles = [
-            np.array([0, 0, 1, 1, 0, 1] * 16, dtype=np.uint16),
-            np.array([0, 0, 0] * 16 + [0, 1, 0] + [0, 0, 0] * 15, dtype=np.uint16),
-            np.array([0, 0, 0] * 16 + [1, 0, 0] + [0, 0, 0] * 15, dtype=np.uint16),
-        ]
-        for max_rate in range(self.MAX_RATE):
-            for n in range(self.MAX_RATE):
-                for irreducible in irreducibles:
-                    data = np.tile(np.tile(irreducible.reshape(-1, 1, 3), (1, 2**n, 1)).ravel(), (16,))
-
-                    dyn_n = BinaryWaveform(data).dynamic_rate(max_rate=max_rate)
-
-                    self.assertEqual(min(max_rate, n), dyn_n)
-
-
-def make_binary_waveform(waveform):
-    if zhinst is None:
-        # TODO: mock used function
-        raise unittest.SkipTest("zhinst not present")
-
-    if waveform.duration == 0:
-        data = np.asarray(3 * [1, 2, 3, 4, 5], dtype=np.uint16)
-        return (BinaryWaveform(data),)
-    else:
-        chs = sorted(waveform.defined_channels)
-        t = np.arange(0., float(waveform.duration), 1.)
-
-        sampled = [None if ch is None else waveform.get_sampled(ch, t)
-                   for _, ch in zip_longest(range(6), take(6, chs), fillvalue=None)]
-        ch1, ch2, *markers = sampled
-        return (BinaryWaveform.from_sampled(ch1, ch2, markers),)
-
-
-def _key_to_int(n: int, duration: int, defined_channels: frozenset):
-    key_bytes = str((n, duration, sorted(defined_channels))).encode('ascii')
-    key_int64 = int(hashlib.sha256(key_bytes).hexdigest()[:2*8], base=16) // 2
-    return key_int64
-
-
-def get_unique_wfs(n=10000, duration=32, defined_channels=frozenset(['A'])):
-    if not hasattr(get_unique_wfs, 'cache'):
-        get_unique_wfs.cache = {}
-
-    key = (n, duration, defined_channels)
-
-    if key not in get_unique_wfs.cache:
-        # positive deterministic int64
-        h = _key_to_int(n, duration, defined_channels)
-        base = np.bitwise_xor(np.linspace(-h, h, num=duration + n, dtype=np.int64), h)
-        base = base / np.max(np.abs(base))
-
-        get_unique_wfs.cache[key] = [
-            DummyWaveform(duration=duration, sample_output=base[idx:idx+duration],
-                          defined_channels=defined_channels)
-            for idx in range(n)
-        ]
-    return get_unique_wfs.cache[key]
-
-
-def get_constant_unique_wfs(n=10000, duration=192, defined_channels=frozenset(['A'])):
-    if not hasattr(get_unique_wfs, 'cache'):
-        get_unique_wfs.cache = {}
-
-    key = (n, duration, defined_channels)
-
-    if key not in get_unique_wfs.cache:
-        bit_gen = np.random.PCG64(_key_to_int(n, duration, defined_channels))
-        rng = np.random.Generator(bit_gen)
-
-        random_values = rng.random(size=(n, len(defined_channels)))
-
-        sorted_channels = sorted(defined_channels)
-        get_unique_wfs.cache[key] = [
-            ConstantWaveform.from_mapping(duration, {ch: ch_value
-                                                     for ch, ch_value in zip(sorted_channels, wf_values)})
-            for wf_values in random_values
-        ]
-    return get_unique_wfs.cache[key]
-
-
-def complex_program_as_loop(unique_wfs, wf_same):
-    root = Loop(repetition_count=12)
-
-    for wf_unique in unique_wfs:
-        root.append_child(children=[Loop(repetition_count=42, waveform=wf_unique),
-                                    Loop(repetition_count=98, waveform=wf_same)],
-                          repetition_count=10)
-
-    root.append_child(waveform=unique_wfs[0], repetition_count=21)
-    root.append_child(waveform=wf_same, repetition_count=23)
-
-    volatile_repetition = VolatileRepetitionCount(ExpressionScalar('n + 4'),
-                                                  DictScope.from_kwargs(n=3, volatile={'n'}))
-    root.append_child(waveform=wf_same, repetition_count=volatile_repetition)
-
-    return root
-
-
-def complex_program_as_seqc(unique_wfs, wf_same):
-    return Repeat(12,
-           Scope([
-               SteppingRepeat([
-                   Repeat(repetition_count=10, scope=Scope([
-                       Repeat(42, WaveformPlayback(make_binary_waveform(unique_wf))),
-                       Repeat(98, WaveformPlayback(make_binary_waveform(wf_same), shared=True)),
-                   ]))
-                   for unique_wf in unique_wfs
-               ]),
-               Repeat(21, WaveformPlayback(make_binary_waveform(unique_wfs[0]))),
-               Repeat(23, WaveformPlayback(make_binary_waveform(wf_same))),
-               Repeat('test_14', WaveformPlayback(make_binary_waveform(wf_same)))
-           ])
-           )
-
-
-class DummyWfManager:
-    def __init__(self):
-        self.shared = {}
-        self.concatenated = []
-
-    def request_shared(self, wf):
-        return self.shared.setdefault(wf, len(self.shared) + 1)
-
-    def request_concatenated(self, wf):
-        self.concatenated.append(wf)
-        return 0
-
-
-class WaveformFileSystemTests(TestCase):
-    def setUp(self) -> None:
-        clients = [mock.Mock(), mock.Mock()]
-        bin_waveforms = [mock.Mock(), mock.Mock(), mock.Mock()]
-        table_data = [np.ones(1, dtype=np.uint16) * i for i, _ in enumerate(bin_waveforms)]
-        for bin_wf, tab in zip(bin_waveforms, table_data):
-            bin_wf.to_csv_compatible_table.return_value = tab
-
-        self.temp_dir = tempfile.TemporaryDirectory()
-        self.table_data = table_data
-        self.clients = clients
-        self.waveforms = [
-            {'0': bin_waveforms[0], '1': bin_waveforms[1]},
-            {'1': bin_waveforms[1], '2': bin_waveforms[2]}
-        ]
-        self.fs = WaveformFileSystem(pathlib.Path(self.temp_dir.name))
-
-    def read_files(self) -> dict:
-        return {
-            p.name: p.read_text().strip() for p in self.fs._path.iterdir()
-        }
-
-    def tearDown(self) -> None:
-        self.temp_dir.cleanup()
-
-    def test_pub_sync(self):
-        with mock.patch.object(self.fs, '_sync') as mock_sync:
-            self.fs.sync(self.clients[0], self.waveforms[0], hallo=0)
-            mock_sync.assert_called_once_with(hallo=0)
-
-            self.assertEqual({id(self.clients[0]): self.waveforms[0]}, self.fs._required)
-
-    def test_sync(self):
-        self.fs.sync(self.clients[0], self.waveforms[0])
-        self.assertEqual({'0': '0', '1': '1'}, self.read_files())
-
-        self.fs.sync(self.clients[0], self.waveforms[1])
-        self.assertEqual({'2': '2', '1': '1'}, self.read_files())
-
-        self.fs.sync(self.clients[1], self.waveforms[0])
-        self.assertEqual({'2': '2', '1': '1', '0': '0'}, self.read_files())
-
-    def test_sync_write_all(self):
-        self.fs.sync(self.clients[0], self.waveforms[0])
-        self.assertEqual({'0': '0', '1': '1'}, self.read_files())
-
-        self.table_data[0][:] = 7
-        self.fs.sync(self.clients[0], self.waveforms[0])
-        self.assertEqual({'0': '0', '1': '1'}, self.read_files())
-
-        self.fs.sync(self.clients[0], self.waveforms[0], write_all=True)
-        self.assertEqual({'0': '7', '1': '1'}, self.read_files())
-
-    def test_sync_no_delete(self):
-        self.fs.sync(self.clients[0], self.waveforms[0])
-        self.assertEqual({'0': '0', '1': '1'}, self.read_files())
-
-        self.fs.sync(self.clients[0], self.waveforms[1], delete=False)
-        self.assertEqual({'2': '2', '1': '1', '0': '0'}, self.read_files())
-
-
-class SEQCNodeTests(TestCase):
-    """Test everything besides source code generation"""
-    @unittest.skipIf(zhinst is None, "test requires zhinst")
-    def test_visit_nodes(self):
-        wf, wf_shared = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2)))
-        wf_shared.shared = True
-
-        waveform_manager = mock.Mock(wraps=DummyWfManager())
-        wf._visit_nodes(waveform_manager)
-        waveform_manager.request_concatenated.assert_called_once_with(wf.waveform)
-
-        waveform_manager = mock.Mock(wraps=DummyWfManager())
-        wf_shared._visit_nodes(waveform_manager)
-        waveform_manager.request_concatenated.assert_not_called()
-
-        scope = Scope([mock.Mock(wraps=wf), mock.Mock(wraps=wf_shared)])
-        scope._visit_nodes(waveform_manager)
-        scope.nodes[0]._visit_nodes.assert_called_once_with(waveform_manager)
-        scope.nodes[1]._visit_nodes.assert_called_once_with(waveform_manager)
-        waveform_manager.request_concatenated.assert_called_once_with(wf.waveform)
-
-        waveform_manager = mock.Mock(wraps=DummyWfManager())
-        repeat = Repeat(12, mock.Mock(wraps=wf))
-        repeat._visit_nodes(waveform_manager)
-        repeat.scope._visit_nodes.assert_called_once_with(waveform_manager)
-        waveform_manager.request_concatenated.assert_called_once_with(wf.waveform)
-
-        waveform_manager = mock.Mock(wraps=DummyWfManager())
-        stepping_repeat = SteppingRepeat([mock.Mock(wraps=wf), mock.Mock(wraps=wf), mock.Mock(wraps=wf)])
-        stepping_repeat._visit_nodes(waveform_manager)
-        for node in stepping_repeat.node_cluster:
-            node._visit_nodes.assert_called_once_with(waveform_manager)
-
-    @unittest.skipIf(zhinst is None, "test requires zhinst")
-    def test_same_stepping(self):
-        wf1, wf2 = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2, 32)))
-        wf3, wf_shared = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2, 64)))
-        wf_shared.shared = True
-
-        scope1 = Scope([wf1, wf1, wf2])
-        scope2 = Scope([wf1, wf2, wf2])
-        scope3 = Scope([wf1, wf2, wf3])
-        scope4 = Scope([wf1, wf2, wf2, wf2])
-
-        repeat1 = Repeat(13, wf1)
-        repeat2 = Repeat(13, wf2)
-        repeat3 = Repeat(15, wf2)
-        repeat4 = Repeat(13, wf3)
-
-        stepping_repeat1 = SteppingRepeat([wf1, wf1, wf2])
-        stepping_repeat2 = SteppingRepeat([wf2, wf2, wf2])
-        stepping_repeat3 = SteppingRepeat([wf3, wf3, wf3])
-        stepping_repeat4 = SteppingRepeat([wf1, wf1, wf2, wf1])
-
-        self.assertTrue(wf1.same_stepping(wf1))
-        self.assertTrue(wf1.same_stepping(wf2))
-        self.assertFalse(wf1.same_stepping(wf3))
-        self.assertFalse(wf3.same_stepping(wf_shared))
-        self.assertFalse(wf_shared.same_stepping(wf3))
-
-        self.assertFalse(scope1.same_stepping(wf1))
-        self.assertTrue(scope1.same_stepping(scope2))
-        self.assertFalse(scope1.same_stepping(scope3))
-        self.assertFalse(scope1.same_stepping(scope4))
-
-        self.assertFalse(repeat1.same_stepping(scope1))
-        self.assertTrue(repeat1.same_stepping(repeat2))
-        self.assertFalse(repeat1.same_stepping(repeat3))
-        self.assertFalse(repeat1.same_stepping(repeat4))
-
-        self.assertFalse(stepping_repeat1.same_stepping(scope1))
-        self.assertTrue(stepping_repeat1.same_stepping(stepping_repeat2))
-        self.assertFalse(stepping_repeat1.same_stepping(stepping_repeat3))
-        self.assertFalse(stepping_repeat1.same_stepping(stepping_repeat4))
-
-    @unittest.skipIf(zhinst is None, "test requires zhinst")
-    def test_iter_waveform_playback(self):
-        wf1, wf2 = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2, 32)))
-        wf3, wf_shared = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2, 64)))
-
-        for wf in (wf1, wf2, wf3, wf_shared):
-            pb, = wf.iter_waveform_playbacks()
-            self.assertIs(pb, wf)
-
-        repeat = Repeat(13, wf1)
-        self.assertEqual(list(repeat.iter_waveform_playbacks()), [wf1])
-
-        scope = Scope([wf1, repeat, wf2, wf3, wf_shared])
-        self.assertEqual(list(scope.iter_waveform_playbacks()), [wf1, wf1, wf2, wf3, wf_shared])
-
-        stepping_repeat = SteppingRepeat([wf1, repeat, wf2, wf3, wf_shared])
-        self.assertEqual(list(stepping_repeat.iter_waveform_playbacks()), [wf1, wf1, wf2, wf3, wf_shared])
-
-    @unittest.skipIf(zhinst is None, "test requires zhinst")
-    def test_get_single_indexed_playback(self):
-        wf1, wf_shared = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2, 32)))
-        wf_shared.shared = True
-        self.assertIs(wf1._get_single_indexed_playback(), wf1)
-        self.assertIsNone(wf_shared._get_single_indexed_playback())
-
-        self.assertIs(Scope([wf1, wf_shared])._get_single_indexed_playback(), wf1)
-        self.assertIsNone(Scope([wf1, wf_shared, wf1])._get_single_indexed_playback(), wf1)
-
-    def test_get_position_advance_strategy(self):
-        node = mock.Mock()
-        node.samples.return_value = 0
-        node._get_single_indexed_playback.return_value.samples.return_value = 128
-        repeat = Repeat(10, node)
-
-        # no samples at all
-        self.assertIs(repeat._get_position_advance_strategy(), repeat._AdvanceStrategy.IGNORE)
-        node.samples.assert_called_once_with()
-        node._get_single_indexed_playback.assert_not_called()
-
-        node.reset_mock()
-        node.samples.return_value = 64
-
-        # samples do differ
-        self.assertIs(repeat._get_position_advance_strategy(), repeat._AdvanceStrategy.INITIAL_RESET)
-        node.samples.assert_called_once_with()
-        node._get_single_indexed_playback.assert_called_once_with()
-        node._get_single_indexed_playback.return_value.samples.assert_called_once_with()
-
-        node.reset_mock()
-        node.samples.return_value = 128
-
-        # samples are the same
-        self.assertIs(repeat._get_position_advance_strategy(), repeat._AdvanceStrategy.POST_ADVANCE)
-        node.samples.assert_called_once_with()
-        node._get_single_indexed_playback.assert_called_once_with()
-        node._get_single_indexed_playback.return_value.samples.assert_called_once_with()
-
-        node.reset_mock()
-        node._get_single_indexed_playback.return_value = None
-        # multiple indexed playbacks
-        self.assertIs(repeat._get_position_advance_strategy(), repeat._AdvanceStrategy.INITIAL_RESET)
-        node.samples.assert_called_once_with()
-        node._get_single_indexed_playback.assert_called_once_with()
-
-
-@unittest.skipIf(zhinst is None, "test requires zhinst")
-class LoopToSEQCTranslationTests(TestCase):
-    def test_loop_to_seqc_leaf(self):
-        """Test the translation of leaves"""
-        # we use None because it is not used in this test
-        user_registers = None
-
-        wf = DummyWaveform(duration=32, sample_output=lambda x: np.sin(x))
-        loop = Loop(waveform=wf)
-
-        # with wrapping repetition
-        loop.repetition_count = 15
-        waveform_to_bin = mock.Mock(wraps=make_binary_waveform)
-        expected = Repeat(loop.repetition_count, WaveformPlayback(waveform=make_binary_waveform(wf)))
-        result = loop_to_seqc(loop, 1, 1, waveform_to_bin, user_registers=user_registers)
-        waveform_to_bin.assert_called_once_with(wf)
-        self.assertEqual(expected, result)
-
-        # without wrapping repetition
-        loop.repetition_count = 1
-        waveform_to_bin = mock.Mock(wraps=make_binary_waveform)
-        expected = WaveformPlayback(waveform=make_binary_waveform(wf))
-        result = loop_to_seqc(loop, 1, 1, waveform_to_bin, user_registers=user_registers)
-        waveform_to_bin.assert_called_once_with(wf)
-        self.assertEqual(expected, result)
-
-    def test_loop_to_seqc_len_1(self):
-        """Test the translation of loops with len(loop) == 1"""
-        # we use None because it is not used in this test
-        user_registers = None
-
-        loop = Loop(children=[Loop()])
-        waveform_to_bin = mock.Mock(wraps=make_binary_waveform)
-        loop_to_seqc_kwargs = dict(min_repetitions_for_for_loop=2,
-                                   min_repetitions_for_shared_wf=3,
-                                   waveform_to_bin=waveform_to_bin,
-                                   user_registers=user_registers)
-
-        expected = 'asdf'
-        with mock.patch('qupulse._program.seqc.loop_to_seqc', return_value=expected) as mocked_loop_to_seqc:
-            result = loop_to_seqc(loop, **loop_to_seqc_kwargs)
-            self.assertEqual(result, expected)
-            mocked_loop_to_seqc.assert_called_once_with(loop[0], **loop_to_seqc_kwargs)
-
-        loop.repetition_count = 14
-        expected = Repeat(14, 'asdfg')
-        with mock.patch('qupulse._program.seqc.loop_to_seqc', return_value=expected.scope) as mocked_loop_to_seqc:
-            result = loop_to_seqc(loop, **loop_to_seqc_kwargs)
-            self.assertEqual(result, expected)
-            mocked_loop_to_seqc.assert_called_once_with(loop[0], **loop_to_seqc_kwargs)
-
-        waveform_to_bin.assert_not_called()
-
-    def test_to_node_clusters(self):
-        """Test cluster generation"""
-        wf1, wf2 = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2, 32)))
-        wf3, wf_shared = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2, 64)))
-
-        loop_to_seqc_kwargs = {'my': 'kwargs'}
-
-        loops = [wf1, wf2, wf1, wf1, wf3, wf1, wf1, wf1, wf3, wf1, wf3, wf1, wf3]
-        expected_calls = [mock.call(loop, **loop_to_seqc_kwargs) for loop in loops]
-        expected_result = [[wf1, wf2, wf1, wf1], [wf3], [wf1, wf1, wf1], [Scope([wf3, wf1]), Scope([wf3, wf1])], [wf3]]
-
-        with mock.patch('qupulse._program.seqc.loop_to_seqc', wraps=dummy_loop_to_seqc) as mock_loop_to_seqc:
-            result = to_node_clusters(loops, loop_to_seqc_kwargs)
-            self.assertEqual(mock_loop_to_seqc.mock_calls, expected_calls)
-        self.assertEqual(expected_result, result)
-
-    def test_to_node_clusters_crash(self):
-        wf1 = WaveformPlayback(make_binary_waveform(*get_unique_wfs(1, 32)))
-        wf2 = WaveformPlayback(make_binary_waveform(*get_unique_wfs(1, 64)))
-        wf3 = WaveformPlayback(make_binary_waveform(*get_unique_wfs(1, 128)))
-        wf4 = WaveformPlayback(make_binary_waveform(*get_unique_wfs(1, 256)))
-
-        loop_to_seqc_kwargs = {'my': 'kwargs'}
-
-        loops = [wf1, wf2, wf3] * 3 + [wf1] + [wf2, wf4] * 3 + [wf1]
-        with mock.patch('qupulse._program.seqc.loop_to_seqc', wraps=dummy_loop_to_seqc) as mock_loop_to_seqc:
-            result = to_node_clusters(loops, loop_to_seqc_kwargs)
-        expected_result = [[Scope([wf1, wf2, wf3])]*3, [wf1], [Scope([wf2, wf4])]*3, [wf1]]
-        self.assertEqual(expected_result, result)
-
-    def test_find_sharable_waveforms(self):
-        wf1, wf2 = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2, 32)))
-        wf3, wf_shared = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(2, 64)))
-
-        scope1 = Scope([wf1, wf1, wf_shared, wf1])
-        scope2 = Scope([wf1, wf2, wf_shared, wf2])
-        scope3 = Scope([wf2, wf2, wf_shared, wf3])
-        scope4 = Scope([wf2, wf2, wf3,       wf3])
-
-        self.assertIsNone(find_sharable_waveforms([scope1, scope2, scope3, scope4]))
-
-        shareable = find_sharable_waveforms([scope1, scope2, scope3])
-        self.assertEqual([False, False, True, False], shareable)
-
-    def test_mark_sharable_waveforms(self):
-        shareable = [False, False, True, False]
-        
-        pb_gen = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(12, 32)))
-        
-        nodes = [Scope([mock.Mock(wraps=next(pb_gen)), mock.Mock(wraps=next(pb_gen)), mock.Mock(wraps=next(pb_gen)), mock.Mock(wraps=next(pb_gen))]),
-                 Scope([mock.Mock(wraps=next(pb_gen)), mock.Mock(wraps=next(pb_gen)), mock.Mock(wraps=next(pb_gen)), mock.Mock(wraps=next(pb_gen))]),
-                 Scope([mock.Mock(wraps=next(pb_gen)), mock.Mock(wraps=next(pb_gen)), mock.Mock(wraps=next(pb_gen)), mock.Mock(wraps=next(pb_gen))])]
-
-        mocks = [mock.Mock(wraps=scope) for scope in nodes]
-
-        mark_sharable_waveforms(mocks, shareable)
-
-        for mock_scope, scope in zip(mocks, nodes):
-            mock_scope.iter_waveform_playbacks.assert_called_once_with()
-            m1, m2, m3, m4 = scope.nodes
-            self.assertIsInstance(m1.shared, mock.Mock)
-            m1.iter_waveform_playbacks.assert_called_once_with()
-            self.assertIsInstance(m2.shared, mock.Mock)
-            m2.iter_waveform_playbacks.assert_called_once_with()
-            self.assertTrue(m3.shared)
-            m3.iter_waveform_playbacks.assert_called_once_with()
-            self.assertIsInstance(m4.shared, mock.Mock)
-            m4.iter_waveform_playbacks.assert_called_once_with()
-
-    def test_loop_to_seqc_cluster_handling(self):
-        """Test handling of clusters"""
-
-        # we use None because it is not used in this test
-        user_registers = None
-
-        with self.assertRaises(AssertionError):
-            loop_to_seqc(Loop(repetition_count=12, children=[Loop()]),
-                         min_repetitions_for_for_loop=3, min_repetitions_for_shared_wf=2,
-                         waveform_to_bin=make_binary_waveform, user_registers=user_registers)
-
-        loop_to_seqc_kwargs = dict(min_repetitions_for_for_loop=3,
-                                   min_repetitions_for_shared_wf=4,
-                                   waveform_to_bin=make_binary_waveform, user_registers=user_registers)
-
-        wf_same = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(100000, 32)))
-        wf_sep, = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(1, 64)))
-
-        node_clusters = [take(2, wf_same), [wf_sep],
-                         take(3, wf_same), [wf_sep],
-                         take(4, wf_same), take(4, wf_same)]
-        root = Loop(repetition_count=12, children=[Loop() for _ in range(2 + 1 + 3 + 1 + 4 + 1 + 4)])
-
-        expected = Repeat(12, Scope([
-            *node_clusters[0],
-            wf_sep,
-            SteppingRepeat(node_clusters[2]),
-            wf_sep,
-            SteppingRepeat(node_clusters[4]),
-            SteppingRepeat(node_clusters[5])
-        ]))
-
-        def dummy_find_sharable_waveforms(cluster):
-            if cluster is node_clusters[4]:
-                return [True]
-            else:
-                return None
-
-        p1 = mock.patch('qupulse._program.seqc.to_node_clusters', return_value=node_clusters)
-        p2 = mock.patch('qupulse._program.seqc.find_sharable_waveforms', wraps=dummy_find_sharable_waveforms)
-        p3 = mock.patch('qupulse._program.seqc.mark_sharable_waveforms')
-
-        with p1 as to_node_clusters_mock, p2 as find_share_mock, p3 as mark_share_mock:
-            result = loop_to_seqc(root, **loop_to_seqc_kwargs)
-            self.assertEqual(expected, result)
-
-            to_node_clusters_mock.assert_called_once_with(root, loop_to_seqc_kwargs)
-            self.assertEqual(find_share_mock.mock_calls,
-                             [mock.call(node_clusters[4]), mock.call(node_clusters[5])])
-            mark_share_mock.assert_called_once_with(node_clusters[4], [True])
-
-    def test_program_translation(self):
-        """Integration test"""
-        user_registers = UserRegisterManager(range(14, 15), 'test_{register}')
-
-        unique_wfs = get_unique_wfs()
-        same_wf = DummyWaveform(duration=32, sample_output=np.ones(32))
-        root = complex_program_as_loop(unique_wfs, wf_same=same_wf)
-
-        t0 = time.perf_counter()
-
-        seqc = loop_to_seqc(root, 50, 100, make_binary_waveform, user_registers=user_registers)
-
-        t1 = time.perf_counter()
-        print('took', t1 - t0, 's')
-
-        expected = complex_program_as_seqc(unique_wfs, wf_same=same_wf)
-        self.assertEqual(expected, seqc)
-
-
-@unittest.skipIf(zhinst is None, "test requires zhinst")
-class SEQCToCodeTranslationTests(TestCase):
-    def setUp(self) -> None:
-        self.line_prefix = '   '
-        self.node_name_generator = map(str, range(10000000000000000000))
-        self.pos_var_name = 'foo'
-        self.waveform_manager = DummyWfManager()
-
-    def test_shared_playback(self):
-        wf, = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(1, 32)))
-        wf.shared = True
-
-        expected = ['   playWave(1);']
-        result = list(wf.to_source_code(self.waveform_manager, self.node_name_generator, self.line_prefix, self.pos_var_name, True))
-        self.assertEqual(expected, result)
-
-    def test_indexed_playback(self):
-        wf, = map(WaveformPlayback, map(make_binary_waveform, get_unique_wfs(1, 32)))
-
-        expected = ['   playWaveIndexed(0, foo, 32); foo = foo + 32;']
-        result = list(
-            wf.to_source_code(self.waveform_manager, self.node_name_generator, self.line_prefix, self.pos_var_name,
-                              True))
-        self.assertEqual(expected, result)
-
-        expected = ['   playWaveIndexed(0, foo, 32);' + wf.ADVANCE_DISABLED_COMMENT]
-        result = list(
-            wf.to_source_code(self.waveform_manager, self.node_name_generator, self.line_prefix, self.pos_var_name,
-                              False))
-        self.assertEqual(expected, result)
-
-    def test_scope(self):
-        nodes = [mock.Mock(), mock.Mock(), mock.Mock()]
-        for idx, node in enumerate(nodes):
-            node.to_source_code = mock.Mock(return_value=map(str, [idx + 100, idx + 200]))
-
-        scope = Scope(nodes)
-        expected = ['100', '200', '101', '201', '102', '202']
-        result = list(scope.to_source_code(self.waveform_manager, self.node_name_generator,
-                                           self.line_prefix, self.pos_var_name, False))
-        self.assertEqual(expected, result)
-        for node in nodes:
-            node.to_source_code.assert_called_once_with(self.waveform_manager,
-                                                        line_prefix=self.line_prefix,
-                                                        pos_var_name=self.pos_var_name,
-                                                        node_name_generator=self.node_name_generator,
-                                                        advance_pos_var=False)
-
-    def test_stepped_repeat(self):
-        nodes = [mock.Mock(), mock.Mock(), mock.Mock()]
-        for idx, node in enumerate(nodes):
-            node.to_source_code = mock.Mock(return_value=map(str, [idx + 100, idx + 200]))
-
-        stepping_repeat = SteppingRepeat(nodes)
-
-        body_prefix = self.line_prefix + stepping_repeat.INDENTATION
-        expected = [
-            '   repeat(3) {' + stepping_repeat.STEPPING_REPEAT_COMMENT,
-            '100',
-            '200',
-            '   }'
-        ]
-        result = list(stepping_repeat.to_source_code(self.waveform_manager, self.node_name_generator,
-                                                     self.line_prefix, self.pos_var_name, False))
-        self.assertEqual(expected, result)
-        nodes[0].to_source_code.assert_called_once_with(self.waveform_manager,
-                                                        line_prefix=body_prefix,
-                                                        pos_var_name=self.pos_var_name,
-                                                        node_name_generator=self.node_name_generator,
-                                                        advance_pos_var=False)
-        nodes[1].to_source_code.assert_not_called()
-        nodes[2].to_source_code.assert_not_called()
-        nodes[0]._visit_nodes.assert_not_called()
-        nodes[1]._visit_nodes.assert_called_once_with(self.waveform_manager)
-        nodes[2]._visit_nodes.assert_called_once_with(self.waveform_manager)
-
-    def test_repeat(self):
-        node = mock.Mock()
-        node.to_source_code = mock.Mock(return_value=['asd', 'jkl'])
-        node._get_single_indexed_playback = mock.Mock(return_value=None)
-        node.samples = mock.Mock(return_value=64)
-
-        repeat = Repeat(12, node)
-
-        body_prefix = self.line_prefix + repeat.INDENTATION
-        expected = ['   var init_pos_0 = foo;',
-                    '   repeat(12) {',
-                    '     foo = init_pos_0;',
-                    'asd', 'jkl', '   }']
-
-        result = list(repeat.to_source_code(self.waveform_manager,
-                                            node_name_generator=self.node_name_generator,
-                                            line_prefix=self.line_prefix, pos_var_name=self.pos_var_name,
-                                            advance_pos_var=True))
-        self.assertEqual(expected, result)
-        node.to_source_code.assert_called_once_with(self.waveform_manager, node_name_generator=self.node_name_generator,
-                                                    line_prefix=body_prefix,
-                                                    pos_var_name=self.pos_var_name,
-                                                    advance_pos_var=True)
-        node._get_single_indexed_playback.assert_called_once_with()
-        node.samples.assert_called_once_with()
-
-    def test_repeat_detect_no_advance(self):
-        node = mock.Mock()
-        node.to_source_code = mock.Mock(return_value=['asd', 'jkl'])
-        node._get_single_indexed_playback = mock.Mock(return_value=None)
-        node.samples = mock.Mock(return_value=0)
-
-        repeat = Repeat(12, node)
-        body_prefix = self.line_prefix + repeat.INDENTATION
-
-        expected = ['   repeat(12) {',
-                    'asd', 'jkl', '   }']
-        result_no_advance = list(repeat.to_source_code(self.waveform_manager,
-                                                       node_name_generator=self.node_name_generator,
-                                                       line_prefix=self.line_prefix, pos_var_name=self.pos_var_name,
-                                                       advance_pos_var=True))
-        self.assertEqual(expected, result_no_advance)
-        node.to_source_code.assert_called_once_with(self.waveform_manager, node_name_generator=self.node_name_generator,
-                                                    line_prefix=body_prefix,
-                                                    pos_var_name=self.pos_var_name,
-                                                    advance_pos_var=False)
-        node._get_single_indexed_playback.assert_not_called()
-        node.samples.assert_called_once_with()
-
-    def test_repeat_extern_no_advance(self):
-        node = mock.Mock()
-        node.to_source_code = mock.Mock(return_value=['asd', 'jkl'])
-        node._get_single_indexed_playback = mock.Mock(return_value=None)
-        node.samples = mock.Mock(return_value=64)
-
-        repeat = Repeat(12, node)
-
-        body_prefix = self.line_prefix + repeat.INDENTATION
-
-        expected = ['   repeat(12) {',
-                    'asd', 'jkl', '   }']
-        result_no_advance = list(repeat.to_source_code(self.waveform_manager,
-                                                       node_name_generator=self.node_name_generator,
-                                                       line_prefix=self.line_prefix, pos_var_name=self.pos_var_name,
-                                                       advance_pos_var=False))
-        self.assertEqual(expected, result_no_advance)
-        node.to_source_code.assert_called_once_with(self.waveform_manager, node_name_generator=self.node_name_generator,
-                                                    line_prefix=body_prefix,
-                                                    pos_var_name=self.pos_var_name,
-                                                    advance_pos_var=False)
-        node._get_single_indexed_playback.assert_not_called()
-        node.samples.assert_not_called()
-
-    def test_program_to_code_translation(self):
-        """Integration test"""
-        unique_wfs = get_unique_wfs()
-        same_wf = DummyWaveform(duration=48, sample_output=np.ones(48))
-        seqc_nodes = complex_program_as_seqc(unique_wfs, wf_same=same_wf)
-
-        wf_manager = DummyWfManager()
-        def node_name_gen():
-            for i in range(100):
-                yield str(i)
-
-        seqc_code = '\n'.join(seqc_nodes.to_source_code(wf_manager,
-                                                        line_prefix='',
-                                                        pos_var_name='pos',
-                                                        node_name_generator=node_name_gen()))
-        # this is just copied from the result...
-        expected = """var init_pos_0 = pos;
-repeat(12) {
-  pos = init_pos_0;
-  repeat(10000) { // stepping repeat
-    repeat(10) {
-      repeat(42) {
-        playWaveIndexed(0, pos, 32); // advance disabled do to parent repetition
-      }
-      repeat(98) {
-        playWave(1);
-      }
-    }
-    pos = pos + 32;
-  }
-  repeat(21) {
-    playWaveIndexed(0, pos, 32); // advance disabled do to parent repetition
-  }
-  pos = pos + 32;
-  repeat(23) {
-    playWaveIndexed(0, pos, 48); // advance disabled do to parent repetition
-  }
-  pos = pos + 48;
-  var idx_1;
-  for(idx_1 = 0; idx_1 < test_14; idx_1 = idx_1 + 1) {
-    playWaveIndexed(0, pos, 48); // advance disabled do to parent repetition
-  }
-  pos = pos + 48;
-}"""
-        self.assertEqual(expected, seqc_code)
-
-
-class UserRegisterTest(unittest.TestCase):
-    def test_conversions(self):
-        reg = UserRegister(zero_based_value=3)
-        self.assertEqual(3, reg.to_seqc())
-        self.assertEqual(3, reg.to_labone())
-        self.assertEqual(4, reg.to_web_interface())
-
-        reg = UserRegister(one_based_value=4)
-        self.assertEqual(3, reg.to_seqc())
-        self.assertEqual(3, reg.to_labone())
-        self.assertEqual(4, reg.to_web_interface())
-
-        self.assertEqual(reg, UserRegister.from_seqc(3))
-        self.assertEqual(reg, UserRegister.from_labone(3))
-        self.assertEqual(reg, UserRegister.from_web_interface(4))
-
-    def test_formatting(self):
-        reg = UserRegister.from_seqc(3)
-
-        with self.assertRaises(ValueError):
-            '{}'.format(reg)
-
-        self.assertEqual('3', '{:seqc}'.format(reg))
-        self.assertEqual('4', '{:web}'.format(reg))
-        self.assertEqual('UserRegister(zero_based_value=3)', repr(reg))
-        self.assertEqual(repr(reg), '{:r}'.format(reg))
-
-
-class UserRegisterManagerTest(unittest.TestCase):
-    def test_require(self):
-        manager = UserRegisterManager([7, 8, 9], 'test{register}')
-
-        required = [manager.request(0), manager.request(1), manager.request(2)]
-
-        self.assertEqual({'test7', 'test8', 'test9'}, set(required))
-        self.assertEqual(required[1], manager.request(1))
-
-        with self.assertRaisesRegex(ValueError, "No register"):
-            manager.request(3)
-
-
-class HDAWGProgramManagerTest(unittest.TestCase):
-    @unittest.skipIf(sys.version_info.minor < 6, "This test requires dict to be ordered.")
-    def test_full_run(self):
-        defined_channels = frozenset(['A', 'B', 'C'])
-
-        unique_n = 1000
-        unique_duration = 32
-
-        unique_wfs = get_unique_wfs(n=unique_n, duration=unique_duration, defined_channels=defined_channels)
-        same_wf = DummyWaveform(duration=48, sample_output=np.ones(48), defined_channels=defined_channels)
-
-        channels = ('A', 'B')
-        markers = ('C', None, 'A', None)
-        amplitudes = (1., 1.)
-        offsets = (0., 0.)
-        volatage_transformations = (lambda x: x, lambda x: x)
-        sample_rate = 1
-
-        root = complex_program_as_loop(unique_wfs, wf_same=same_wf)
-        seqc_nodes = complex_program_as_seqc(unique_wfs, wf_same=same_wf)
-
-        manager = HDAWGProgramManager()
-
-        manager.add_program('test', root, channels, markers, amplitudes, offsets, volatage_transformations, sample_rate)
-
-        # 0: Program selection
-        # 1: Trigger
-        self.assertEqual({UserRegister(zero_based_value=2): 7}, manager.get_register_values('test'))
-        seqc_program = manager.to_seqc_program()
-        expected_program = """const PROG_SEL_REGISTER = 0;
-const TRIGGER_REGISTER = 1;
-const TRIGGER_RESET_MASK = 0b10000000000000000000000000000000;
-const PROG_SEL_NONE = 0;
-const NO_RESET_MASK = 0b10000000000000000000000000000000;
-const PLAYBACK_FINISHED_MASK = 0b1000000000000000000000000000000;
-const PROG_SEL_MASK = 0b111111111111111111111111111111;
-const INVERTED_PROG_SEL_MASK = 0b11000000000000000000000000000000;
-const IDLE_WAIT_CYCLES = 300;
-wave test_concatenated_waveform_0 = "c45d955d9dc472d46bf74f7eb9ae2ed4d159adea7d6fe9ce3f48c95423535333";
-wave test_shared_waveform_121f5c6e8822793b3836fb3098fa4591b91d4c205cc2d8afd01ee1bf6956e518 = "121f5c6e8822793b3836fb3098fa4591b91d4c205cc2d8afd01ee1bf6956e518";
-
-// function used by manually triggered programs
-void waitForSoftwareTrigger() {
-  while (true) {
-    var trigger_register = getUserReg(TRIGGER_REGISTER);
-    if (trigger_register & TRIGGER_RESET_MASK) setUserReg(TRIGGER_REGISTER, 0);
-    if (trigger_register) return;
-  }
-}
-
-
-// program definitions
-void test_function() {
-  var pos = 0;
-  var user_reg_2 = getUserReg(2);
-  waitForSoftwareTrigger();
-  var init_pos_1 = pos;
-  repeat(12) {
-    pos = init_pos_1;
-    repeat(1000) { // stepping repeat
-      repeat(10) {
-        repeat(42) {
-          playWaveIndexed(test_concatenated_waveform_0, pos, 32); // advance disabled do to parent repetition
-        }
-        repeat(98) {
-          playWave(test_shared_waveform_121f5c6e8822793b3836fb3098fa4591b91d4c205cc2d8afd01ee1bf6956e518);
-        }
-      }
-      pos = pos + 32;
-    }
-    repeat(21) {
-      playWaveIndexed(test_concatenated_waveform_0, pos, 32); // advance disabled do to parent repetition
-    }
-    pos = pos + 32;
-    repeat(23) {
-      playWaveIndexed(test_concatenated_waveform_0, pos, 48); // advance disabled do to parent repetition
-    }
-    pos = pos + 48;
-    var idx_2;
-    for(idx_2 = 0; idx_2 < user_reg_2; idx_2 = idx_2 + 1) {
-      playWaveIndexed(test_concatenated_waveform_0, pos, 48); // advance disabled do to parent repetition
-    }
-    pos = pos + 48;
-  }
-}
-
-// Declare and initialize global variables
-// Selected program index (0 -> None)
-var prog_sel = 0;
-
-// Value that gets written back to program selection register.
-// Used to signal that at least one program was played completely.
-var new_prog_sel = 0;
-
-// Is OR'ed to new_prog_sel.
-// Set to PLAYBACK_FINISHED_MASK if a program was played completely.
-var playback_finished = 0;
-
-
-// runtime block
-while (true) {
-  // read program selection value
-  prog_sel = getUserReg(PROG_SEL_REGISTER);
-  
-  // calculate value to write back to PROG_SEL_REGISTER
-  new_prog_sel = prog_sel | playback_finished;
-  if (!(prog_sel & NO_RESET_MASK)) new_prog_sel &= INVERTED_PROG_SEL_MASK;
-  setUserReg(PROG_SEL_REGISTER, new_prog_sel);
-  
-  // reset playback flag
-  playback_finished = 0;
-  
-  // only use part of prog sel that does not mean other things to select the program.
-  prog_sel &= PROG_SEL_MASK;
-  
-  switch (prog_sel) {
-    case 1:
-      test_function();
-      waitWave();
-      playback_finished = PLAYBACK_FINISHED_MASK;
-    default:
-      wait(IDLE_WAIT_CYCLES);
-  }
-}"""
-        self.assertEqual(expected_program, seqc_program)
-
-    @unittest.skipIf(sys.version_info.minor < 6, "This test requires dict to be ordered.")
-    def test_full_run_with_dynamic_rate_reduction(self):
-        defined_channels = frozenset(['A', 'B', 'C'])
-
-        unique_n = 1000
-        unique_duration = 192
-
-        unique_wfs = get_constant_unique_wfs(n=unique_n, duration=unique_duration,
-                                             defined_channels=defined_channels)
-        same_wf = DummyWaveform(duration=48, sample_output=np.ones(48), defined_channels=defined_channels)
-
-        channels = ('A', 'B')
-        markers = ('C', None, 'A', None)
-        amplitudes = (1., 1.)
-        offsets = (0., 0.)
-        volatage_transformations = (lambda x: x, lambda x: x)
-        sample_rate = 1
-
-        old_value, WaveformPlayback.ENABLE_DYNAMIC_RATE_REDUCTION = WaveformPlayback.ENABLE_DYNAMIC_RATE_REDUCTION, True
-        try:
-            root = complex_program_as_loop(unique_wfs, wf_same=same_wf)
-            seqc_nodes = complex_program_as_seqc(unique_wfs, wf_same=same_wf)
-
-            manager = HDAWGProgramManager()
-
-            manager.add_program('test', root, channels, markers, amplitudes, offsets, volatage_transformations,
-                                sample_rate)
-        finally:
-            WaveformPlayback.ENABLE_DYNAMIC_RATE_REDUCTION = old_value
-
-
-
-        # 0: Program selection
-        # 1: Trigger
-        self.assertEqual({UserRegister(zero_based_value=2): 7}, manager.get_register_values('test'))
-        seqc_program = manager.to_seqc_program()
-        expected_program = """const PROG_SEL_REGISTER = 0;
-const TRIGGER_REGISTER = 1;
-const TRIGGER_RESET_MASK = 0b10000000000000000000000000000000;
-const PROG_SEL_NONE = 0;
-const NO_RESET_MASK = 0b10000000000000000000000000000000;
-const PLAYBACK_FINISHED_MASK = 0b1000000000000000000000000000000;
-const PROG_SEL_MASK = 0b111111111111111111111111111111;
-const INVERTED_PROG_SEL_MASK = 0b11000000000000000000000000000000;
-const IDLE_WAIT_CYCLES = 300;
-wave test_concatenated_waveform_0 = "7fd412eb866ad371f717857ea33b309ec458c6c3469c7e51dcffcdce9a8c2679";
-wave test_shared_waveform_121f5c6e8822793b3836fb3098fa4591b91d4c205cc2d8afd01ee1bf6956e518 = "121f5c6e8822793b3836fb3098fa4591b91d4c205cc2d8afd01ee1bf6956e518";
-
-// function used by manually triggered programs
-void waitForSoftwareTrigger() {
-  while (true) {
-    var trigger_register = getUserReg(TRIGGER_REGISTER);
-    if (trigger_register & TRIGGER_RESET_MASK) setUserReg(TRIGGER_REGISTER, 0);
-    if (trigger_register) return;
-  }
-}
-
-
-// program definitions
-void test_function() {
-  var pos = 0;
-  var user_reg_2 = getUserReg(2);
-  waitForSoftwareTrigger();
-  var init_pos_1 = pos;
-  repeat(12) {
-    pos = init_pos_1;
-    repeat(1000) { // stepping repeat
-      repeat(10) {
-        repeat(42) {
-          playWaveIndexed(test_concatenated_waveform_0, pos, 48, 2); // advance disabled do to parent repetition
-        }
-        repeat(98) {
-          playWave(test_shared_waveform_121f5c6e8822793b3836fb3098fa4591b91d4c205cc2d8afd01ee1bf6956e518, 0);
-        }
-      }
-      pos = pos + 48;
-    }
-    repeat(21) {
-      playWaveIndexed(test_concatenated_waveform_0, pos, 48, 2); // advance disabled do to parent repetition
-    }
-    pos = pos + 48;
-    repeat(23) {
-      playWaveIndexed(test_concatenated_waveform_0, pos, 48, 0); // advance disabled do to parent repetition
-    }
-    pos = pos + 48;
-    var idx_2;
-    for(idx_2 = 0; idx_2 < user_reg_2; idx_2 = idx_2 + 1) {
-      playWaveIndexed(test_concatenated_waveform_0, pos, 48, 0); // advance disabled do to parent repetition
-    }
-    pos = pos + 48;
-  }
-}
-
-// Declare and initialize global variables
-// Selected program index (0 -> None)
-var prog_sel = 0;
-
-// Value that gets written back to program selection register.
-// Used to signal that at least one program was played completely.
-var new_prog_sel = 0;
-
-// Is OR'ed to new_prog_sel.
-// Set to PLAYBACK_FINISHED_MASK if a program was played completely.
-var playback_finished = 0;
-
-
-// runtime block
-while (true) {
-  // read program selection value
-  prog_sel = getUserReg(PROG_SEL_REGISTER);
-  
-  // calculate value to write back to PROG_SEL_REGISTER
-  new_prog_sel = prog_sel | playback_finished;
-  if (!(prog_sel & NO_RESET_MASK)) new_prog_sel &= INVERTED_PROG_SEL_MASK;
-  setUserReg(PROG_SEL_REGISTER, new_prog_sel);
-  
-  // reset playback flag
-  playback_finished = 0;
-  
-  // only use part of prog sel that does not mean other things to select the program.
-  prog_sel &= PROG_SEL_MASK;
-  
-  switch (prog_sel) {
-    case 1:
-      test_function();
-      waitWave();
-      playback_finished = PLAYBACK_FINISHED_MASK;
-    default:
-      wait(IDLE_WAIT_CYCLES);
-  }
-}"""
-        self.assertEqual(expected_program, seqc_program)
\ No newline at end of file
diff --git a/tests/_program/tabor_tests.py b/tests/_program/tabor_tests.py
index ab8cddd30..bb1b7386b 100644
--- a/tests/_program/tabor_tests.py
+++ b/tests/_program/tabor_tests.py
@@ -1,6 +1,7 @@
 import unittest
 import itertools
 import numpy as np
+from copy import deepcopy
 from qupulse.utils.types import FrozenDict
 from unittest import mock
 
@@ -15,9 +16,10 @@
 except ImportError:
     pytabor = None
 
-from qupulse._program.tabor import TaborException, TaborProgram, \
+from qupulse._program.tabor import TaborException, TaborProgram, find_place_for_segments_in_memory,\
     TaborSegment, TaborSequencing, PlottableProgram, TableDescription, make_combined_wave, TableEntry
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
+from qupulse.program.waveforms import ConstantWaveform, SubsetWaveform
 from qupulse._program.volatile import VolatileRepetitionCount
 from qupulse.hardware.util import voltage_to_uint16
 from qupulse.utils.types import TimeType
@@ -231,8 +233,8 @@ def test_depth_1_single_waveform(self):
         self.assertEqual(t_program.get_advanced_sequencer_table(), [TableDescription(1, 1, 0)])
 
     def test_depth_1_single_sequence(self):
-        program = Loop(children=[Loop(waveform=DummyWaveform(defined_channels={'A'}, duration=1), repetition_count=3),
-                                 Loop(waveform=DummyWaveform(defined_channels={'A'}, duration=1), repetition_count=4)],
+        program = Loop(children=[Loop(waveform=DummyWaveform(sample_output={'A': 0.1}, duration=1), repetition_count=3),
+                                 Loop(waveform=DummyWaveform(sample_output={'A': 0.2}, duration=1), repetition_count=4)],
                        repetition_count=1)
 
         t_program = TaborProgram(program, channels=(None, 'A'), markers=(None, None),
@@ -246,8 +248,8 @@ def test_depth_1_single_sequence(self):
 
     def test_depth_1_single_sequence_2(self):
         """Use the same wf twice"""
-        wf_1 = DummyWaveform(defined_channels={'A'}, duration=1)
-        wf_2 = DummyWaveform(defined_channels={'A'}, duration=1)
+        wf_1 = DummyWaveform(sample_output={'A': 0.1}, duration=1)
+        wf_2 = DummyWaveform(sample_output={'A': 0.2}, duration=1)
 
         program = Loop(children=[Loop(waveform=wf_1, repetition_count=3),
                                  Loop(waveform=wf_2, repetition_count=4),
@@ -265,8 +267,8 @@ def test_depth_1_single_sequence_2(self):
         self.assertEqual(t_program.get_advanced_sequencer_table(), [TableDescription(1, 1, 0)])
 
     def test_depth_1_advanced_sequence_unroll(self):
-        wf_1 = DummyWaveform(defined_channels={'A'}, duration=1)
-        wf_2 = DummyWaveform(defined_channels={'A'}, duration=1)
+        wf_1 = DummyWaveform(sample_output={'A': 0.1}, duration=1)
+        wf_2 = DummyWaveform(sample_output={'A': 0.2}, duration=1)
 
         program = Loop(children=[Loop(waveform=wf_1, repetition_count=3),
                                  Loop(waveform=wf_2, repetition_count=4)],
@@ -284,8 +286,8 @@ def test_depth_1_advanced_sequence_unroll(self):
         self.assertEqual(t_program.get_advanced_sequencer_table(), [TableEntry(5, 1, 0)])
 
     def test_depth_1_advanced_sequence(self):
-        wf_1 = DummyWaveform(defined_channels={'A'}, duration=1)
-        wf_2 = DummyWaveform(defined_channels={'A'}, duration=1)
+        wf_1 = DummyWaveform(sample_output={'A': 0.1}, duration=1)
+        wf_2 = DummyWaveform(sample_output={'A': 0.2}, duration=1)
 
         program = Loop(children=[Loop(waveform=wf_1, repetition_count=3),
                                  Loop(waveform=wf_2, repetition_count=4),
@@ -379,13 +381,31 @@ def my_gen(gen):
             np.testing.assert_equal(sampled_seg.ch_a, data[0])
             np.testing.assert_equal(sampled_seg.ch_b, data[1])
 
+    def test_calc_sampled_segments_deduplication(self):
+        wf1 = ConstantWaveform(duration=2, amplitude=0.1, channel='A')
+        wf2 = SubsetWaveform(
+            ConstantWaveform.from_mapping(duration=2, constant_values={'A': 0.1, 'B': 0.2}),
+            {'A'}
+        )
+        wf3 = ConstantWaveform(duration=1, amplitude=0.2, channel='A')
+
+        loop = Loop(children=[
+            Loop(waveform=wf1),
+            Loop(waveform=wf2),
+            Loop(waveform=wf3),
+        ])
+        prog = TaborProgram(loop, self.instr_props, ('A', None), (None, None), **self.program_entry_kwargs)
+        sampled, sampled_length = prog.get_sampled_segments()
+        self.assertEqual(len(sampled), 2)
+        self.assertEqual([192 * 2, 192], list(sampled_length))
+
     def test_update_volatile_parameters_with_depth1(self):
         parameters = {'s': 10, 'not': 13}
         s = VolatileRepetitionCount(expression=ExpressionScalar('s'), scope=DictScope(values=FrozenDict(s=3),
                                                                                       volatile=set('s')))
 
-        wf_1 = DummyWaveform(defined_channels={'A'}, duration=1)
-        wf_2 = DummyWaveform(defined_channels={'A'}, duration=1)
+        wf_1 = DummyWaveform(sample_output={'A': 0.1}, duration=1)
+        wf_2 = DummyWaveform(sample_output={'A': 0.2}, duration=1)
 
         program = Loop(children=[Loop(waveform=wf_1, repetition_count=s),
                                  Loop(waveform=wf_2, repetition_count=4),
@@ -418,8 +438,8 @@ def test_update_volatile_parameters_with_depth2(self):
         a = VolatileRepetitionCount(expression=ExpressionScalar('a'),
                                     scope=DictScope(values=FrozenDict(a=5), volatile=set('a')))
 
-        wf_1 = DummyWaveform(defined_channels={'A'}, duration=1)
-        wf_2 = DummyWaveform(defined_channels={'A'}, duration=1)
+        wf_1 = DummyWaveform(sample_output={'A': 0.1}, duration=1)
+        wf_2 = DummyWaveform(sample_output={'A': 0.2}, duration=1)
 
         program = Loop(children=[Loop(children=[Loop(waveform=wf_1, repetition_count=s),
                                                 Loop(waveform=wf_2, repetition_count=4),
@@ -704,3 +724,89 @@ def exec_general(self, data_1, data_2, fill_value=None):
 
         with self.assertRaises(ValueError):
             make_combined_wave(tabor_segments, destination_array=np.ones(16))
+
+
+class TaborMemoryManagementTests(unittest.TestCase):
+    def test_find_place_for_segments_in_memory(self):
+        # empty
+        kwargs = dict(
+            total_capacity=2**20,
+            current_segment_capacities=np.asarray([], dtype=np.uint32),
+            current_segment_hashes=np.asarray([], dtype=np.int64),
+            current_segment_references=np.asarray([], dtype=np.int32),
+        )
+        prev_kwargs = deepcopy(kwargs)
+
+        segments = np.asarray([-5, -6, -7, -8, -9])
+        segment_lengths = 192 + np.asarray([32, 16, 64, 32, 16])
+
+        w2s, ta, ti = find_place_for_segments_in_memory(
+            **kwargs,
+            new_segment_hashes=segments, new_segment_lengths=segment_lengths)
+        self.assertEqual(w2s.tolist(), [-1, -1, -1, -1, -1])
+        self.assertEqual(ta.tolist(), [True, True, True, True, True])
+        self.assertEqual(ti.tolist(), [-1, -1, -1, -1, -1])
+        np.testing.assert_equal(kwargs, prev_kwargs)
+
+        # all new segments
+        kwargs['current_segment_capacities'] = 192 + np.asarray([0, 16, 32, 16, 0], dtype=np.uint32)
+        kwargs['current_segment_hashes'] = np.asarray([1, 2, 3, 4, 5], dtype=np.int64)
+        kwargs['current_segment_references'] = np.asarray([1, 1, 1, 2, 1], dtype=np.int32)
+        prev_kwargs = deepcopy(kwargs)
+
+        w2s, ta, ti = find_place_for_segments_in_memory(new_segment_hashes=segments, new_segment_lengths=segment_lengths, **kwargs)
+        self.assertEqual(w2s.tolist(), [-1, -1, -1, -1, -1])
+        self.assertEqual(ta.tolist(), [True, True, True, True, True])
+        self.assertEqual(ti.tolist(), [-1, -1, -1, -1, -1])
+        np.testing.assert_equal(kwargs, prev_kwargs)
+
+        # some known segments
+        kwargs['current_segment_capacities'] = 192 + np.asarray([0, 16, 32, 64, 0, 16], dtype=np.uint32)
+        kwargs['current_segment_hashes'] = np.asarray([1, 2, 3, -7, 5, -9], dtype=np.int64)
+        kwargs['current_segment_references'] = np.asarray([1, 1, 1, 2, 1, 3], dtype=np.int32)
+        prev_kwargs = deepcopy(kwargs)
+
+        w2s, ta, ti = find_place_for_segments_in_memory(new_segment_hashes=segments, new_segment_lengths=segment_lengths, **kwargs)
+        self.assertEqual(w2s.tolist(), [-1, -1, 3, -1, 5])
+        self.assertEqual(ta.tolist(), [True, True, False, True, False])
+        self.assertEqual(ti.tolist(), [-1, -1, -1, -1, -1])
+        np.testing.assert_equal(kwargs, prev_kwargs)
+
+        # insert some segments with same length
+        kwargs['current_segment_capacities'] = 192 + np.asarray([0, 16, 32, 64, 0, 16], dtype=np.uint32)
+        kwargs['current_segment_hashes'] = np.asarray([1, 2, 3, 4, 5, 6], dtype=np.int64)
+        kwargs['current_segment_references'] = np.asarray([1, 0, 1, 0, 1, 3], dtype=np.int32)
+        prev_kwargs = deepcopy(kwargs)
+
+        w2s, ta, ti = find_place_for_segments_in_memory(new_segment_hashes=segments, new_segment_lengths=segment_lengths, **kwargs)
+        self.assertEqual(w2s.tolist(), [-1, -1, -1, -1, -1])
+        self.assertEqual(ta.tolist(), [True, False, False, True, True])
+        self.assertEqual(ti.tolist(), [-1, 1, 3, -1, -1])
+        np.testing.assert_equal(kwargs, prev_kwargs)
+
+        # insert some segments with smaller length
+        kwargs['current_segment_capacities'] = 192 + np.asarray([0, 80, 32, 64, 96, 16], dtype=np.uint32)
+        kwargs['current_segment_hashes'] = np.asarray([1, 2, 3, 4, 5, 6], dtype=np.int64)
+        kwargs['current_segment_references'] = np.asarray([1, 0, 1, 1, 0, 3], dtype=np.int32)
+        prev_kwargs = deepcopy(kwargs)
+
+        w2s, ta, ti = find_place_for_segments_in_memory(new_segment_hashes=segments, new_segment_lengths=segment_lengths, **kwargs)
+        self.assertEqual(w2s.tolist(), [-1, -1, -1, -1, -1])
+        self.assertEqual(ta.tolist(), [True, True, False, False, True])
+        self.assertEqual(ti.tolist(), [-1, -1, 4, 1, -1])
+        np.testing.assert_equal(kwargs, prev_kwargs)
+
+        # mix everything
+        segments = np.asarray([-5, -6, -7, -8, -9, -10, -11])
+        segment_lengths = 192 + np.asarray([32, 16, 64, 32, 16, 0, 0])
+
+        kwargs['current_segment_capacities'] = 192 + np.asarray([0, 80, 32, 64, 32, 16], dtype=np.uint32)
+        kwargs['current_segment_hashes'] = np.asarray([1, 2, 3, 4, -8, 6], dtype=np.int64)
+        kwargs['current_segment_references'] = np.asarray([1, 0, 1, 0, 1, 0], dtype=np.int32)
+        prev_kwargs = deepcopy(kwargs)
+
+        w2s, ta, ti = find_place_for_segments_in_memory(new_segment_hashes=segments, new_segment_lengths=segment_lengths, **kwargs)
+        self.assertEqual(w2s.tolist(), [-1,    -1,   -1,    4,     -1,     -1, -1])
+        self.assertEqual(ta.tolist(),  [False, True, False, False, True, True, True])
+        self.assertEqual(ti.tolist(),  [1,     -1,   3,     -1,    -1,   -1,   -1])
+        np.testing.assert_equal(kwargs, prev_kwargs)
diff --git a/tests/_program/transformation_tests.py b/tests/_program/transformation_tests.py
index 3f3664822..f9422f1f4 100644
--- a/tests/_program/transformation_tests.py
+++ b/tests/_program/transformation_tests.py
@@ -5,7 +5,7 @@
 
 
 from qupulse.expressions import ExpressionScalar
-from qupulse._program.transformation import LinearTransformation, Transformation, IdentityTransformation,\
+from qupulse.program.transformation import LinearTransformation, Transformation, IdentityTransformation,\
     ChainedTransformation, ParallelChannelTransformation, chain_transformations, OffsetTransformation,\
     ScalingTransformation
 
@@ -43,7 +43,7 @@ def test_chain(self):
 
         self.assertIs(trafo.chain(IdentityTransformation()), trafo)
 
-        with mock.patch('qupulse._program.transformation.chain_transformations',
+        with mock.patch('qupulse.program.transformation.chain_transformations',
                         return_value='asd') as chain_transformations:
             self.assertEqual(trafo.chain(trafo), 'asd')
             chain_transformations.assert_called_once_with(trafo, trafo)
@@ -277,7 +277,7 @@ def test_chain(self):
         trafo = TransformationStub()
         chained = ChainedTransformation(*trafos)
 
-        with mock.patch('qupulse._program.transformation.chain_transformations',
+        with mock.patch('qupulse.program.transformation.chain_transformations',
                         return_value='asd') as chain_transformations:
             self.assertEqual(chained.chain(trafo), 'asd')
             chain_transformations.assert_called_once_with(*trafos, trafo)
diff --git a/tests/_program/waveforms_tests.py b/tests/_program/waveforms_tests.py
index d84b2c763..c62fceb3a 100644
--- a/tests/_program/waveforms_tests.py
+++ b/tests/_program/waveforms_tests.py
@@ -7,10 +7,10 @@
 from qupulse.utils.types import TimeType
 from qupulse.pulses.interpolation import HoldInterpolationStrategy, LinearInterpolationStrategy,\
     JumpInterpolationStrategy
-from qupulse._program.waveforms import MultiChannelWaveform, RepetitionWaveform, SequenceWaveform,\
+from qupulse.program.waveforms import MultiChannelWaveform, RepetitionWaveform, SequenceWaveform,\
     TableWaveformEntry, TableWaveform, TransformingWaveform, SubsetWaveform, ArithmeticWaveform, ConstantWaveform,\
     Waveform, FunctorWaveform, FunctionWaveform, ReversedWaveform
-from qupulse._program.transformation import LinearTransformation
+from qupulse.program.transformation import LinearTransformation
 from qupulse.expressions import ExpressionScalar, Expression
 
 from tests.pulses.sequencing_dummies import DummyWaveform, DummyInterpolationStrategy
diff --git a/tests/backward_compatibility/hardware_test_helper.py b/tests/backward_compatibility/hardware_test_helper.py
index 1314283c2..2f2cef447 100644
--- a/tests/backward_compatibility/hardware_test_helper.py
+++ b/tests/backward_compatibility/hardware_test_helper.py
@@ -4,6 +4,7 @@
 import typing
 import importlib.util
 import sys
+import warnings
 
 from qupulse.serialization import Serializer, FilesystemBackend, PulseStorage
 from qupulse.pulses.pulse_template import PulseTemplate
diff --git a/tests/backward_compatibility/tabor_backward_compatibility_tests.py b/tests/backward_compatibility/tabor_backward_compatibility_tests.py
index 6e44ab796..7b635283e 100644
--- a/tests/backward_compatibility/tabor_backward_compatibility_tests.py
+++ b/tests/backward_compatibility/tabor_backward_compatibility_tests.py
@@ -7,11 +7,9 @@
 import warnings
 
 try:
-    import tabor_control
-except ImportError as err:
-    raise unittest.SkipTest("tabor_control not present") from err
-
-from tests.hardware.tabor_simulator_based_tests import TaborSimulatorManager
+    from tests.hardware.tabor_simulator_based_tests import TaborSimulatorManager
+except ImportError:
+    TaborSimulatorManager = None
 from tests.hardware.dummy_devices import DummyDAC
 from tests.backward_compatibility.hardware_test_helper import LoadingAndSequencingHelper
 
@@ -102,6 +100,7 @@ def read_program(self):
         return self.program_AB, self.program_CD
 
 
+@unittest.skipIf(tabor_control is None, "tabor_control not available")
 class CompleteIntegrationTestHelper(unittest.TestCase):
     data_folder = None
     pulse_name = None
diff --git a/tests/backward_compatibility/zhinst_charge_scan_test.py b/tests/backward_compatibility/zhinst_charge_scan_tests.py
similarity index 98%
rename from tests/backward_compatibility/zhinst_charge_scan_test.py
rename to tests/backward_compatibility/zhinst_charge_scan_tests.py
index 93c0c8038..de932f8b1 100644
--- a/tests/backward_compatibility/zhinst_charge_scan_test.py
+++ b/tests/backward_compatibility/zhinst_charge_scan_tests.py
@@ -100,7 +100,8 @@ def setUpClass(cls):
         cls.test_state = HDAWGLoadingAndSequencingHelper(cls.data_folder, cls.pulse_name)
 
     def test_1_1_deserialization(self):
-        self.test_state.deserialize_pulse()
+        with self.assertWarns(DeprecationWarning):
+            self.test_state.deserialize_pulse()
 
     def test_1_2_deserialization_2018(self) -> None:
         self.test_state.deserialize_pulse_2018()
diff --git a/tests/expressions/__init__.py b/tests/expressions/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/expression_tests.py b/tests/expressions/expression_tests.py
similarity index 96%
rename from tests/expression_tests.py
rename to tests/expressions/expression_tests.py
index 26693821c..e7fd5110a 100644
--- a/tests/expression_tests.py
+++ b/tests/expressions/expression_tests.py
@@ -1,464 +1,469 @@
-import pickle
-import unittest
-import sys
-
-import numpy as np
-import sympy.abc
-from sympy import sympify, Eq
-
-from qupulse.expressions import Expression, ExpressionVariableMissingException, NonNumericEvaluation, ExpressionScalar, ExpressionVector
-from qupulse.utils.types import TimeType
-
-class ExpressionTests(unittest.TestCase):
-    def test_make(self):
-        self.assertTrue(Expression.make('a') == 'a')
-        self.assertTrue(Expression.make('a + b') == 'a + b')
-        self.assertTrue(Expression.make(9) == 9)
-
-        self.assertIsInstance(Expression.make([1, 'a']), ExpressionVector)
-
-        self.assertIsInstance(ExpressionScalar.make('a'), ExpressionScalar)
-        self.assertIsInstance(ExpressionVector.make(['a']), ExpressionVector)
-
-
-class ExpressionVectorTests(unittest.TestCase):
-    def test_evaluate_numeric(self) -> None:
-        e = ExpressionVector(['a * b + c', 'a + d'])
-        params = {
-            'a': 2,
-            'b': 1.5,
-            'c': -7,
-            'd': 9
-        }
-        np.testing.assert_equal(np.array([2 * 1.5 - 7, 2 + 9]),
-                                e.evaluate_numeric(**params))
-
-        with self.assertRaises(NonNumericEvaluation):
-            params['a'] = sympify('h')
-            e.evaluate_numeric(**params)
-
-    def test_evaluate_numeric_2d(self) -> None:
-        e = ExpressionVector([['a * b + c', 'a + d'], ['a', 3]])
-        params = {
-            'a': 2,
-            'b': 1.5,
-            'c': -7,
-            'd': 9
-        }
-        np.testing.assert_equal(np.array([[2 * 1.5 - 7, 2 + 9], [2, 3]]),
-                                e.evaluate_numeric(**params))
-
-        with self.assertRaises(NonNumericEvaluation):
-            params['a'] = sympify('h')
-            e.evaluate_numeric(**params)
-
-    def test_partial_evaluation(self):
-        e = ExpressionVector(['a * b + c', 'a + d'])
-
-        params = {
-            'a': 2,
-            'b': 1.5,
-            'c': -7
-        }
-
-        expected = ExpressionVector([2 * 1.5 - 7, '2 + d'])
-        evaluated = e.evaluate_symbolic(params)
-
-        np.testing.assert_equal(evaluated.underlying_expression, expected.underlying_expression)
-
-    def test_symbolic_evaluation(self):
-        e = ExpressionVector([['a * b + c', 'a + d'], ['a', 3]])
-        params = {
-            'a': 2,
-            'b': 1.5,
-            'c': -7,
-            'd': 9
-        }
-
-        expected = ExpressionVector([[2 * 1.5 - 7, 2 + 9], [2, 3]])
-        evaluated = e.evaluate_symbolic(params)
-
-        np.testing.assert_equal(evaluated.underlying_expression, expected.underlying_expression)
-
-    def test_numeric_expression(self):
-        numbers = np.linspace(1, 2, num=5)
-
-        e = ExpressionVector(numbers)
-
-        np.testing.assert_equal(e.underlying_expression, numbers)
-
-    def test_eq(self):
-        e1 = ExpressionVector([1, 2])
-        e2 = ExpressionVector(['1', '2'])
-        e3 = ExpressionVector(['1', 'a'])
-        e4 = ExpressionVector([1, 'a'])
-        e5 = ExpressionVector([1, 'a', 3])
-        e6 = ExpressionVector([1, 1, '1'])
-        e7 = ExpressionVector(['a'])
-
-        self.assertEqual(e1, e2)
-        self.assertEqual(e3, e4)
-        self.assertNotEqual(e4, e5)
-
-        self.assertEqual(e1, [1, 2])
-        self.assertNotEqual(e6, 1)
-        self.assertEqual(e7, ExpressionScalar('a'))
-
-    def test_hash(self):
-        e1 = ExpressionVector([1, 2])
-        e2 = ExpressionVector(['1', '2'])
-        e7 = ExpressionVector(['a'])
-
-        s = ExpressionScalar('a')
-        self.assertEqual({e1, e7}, {e1, e2, e7, s})
-
-    def test_pickle(self):
-        expr = ExpressionVector([1, 'a + 5', 3])
-        # populate lambdified
-        expr.evaluate_in_scope({'a': 3})
-        dumped = pickle.dumps(expr)
-        loaded = pickle.loads(dumped)
-        self.assertEqual(expr, loaded)
-
-
-class ExpressionScalarTests(unittest.TestCase):
-    def test_format(self):
-        expr = ExpressionScalar('17')
-        e_format = '{:.4e}'.format(expr)
-        self.assertEqual(e_format, "1.7000e+01")
-
-        empty_format = "{}".format(expr)
-        self.assertEqual(empty_format, '17')
-
-        expr_with_var = ExpressionScalar('17*a')
-        with self.assertRaises(TypeError):
-            # throw error on implicit float cast
-            '{:.4e}'.format(expr_with_var)
-
-        empty_format = "{}".format(expr_with_var)
-        self.assertEqual(empty_format, '17*a')
-
-    @unittest.skipIf(sys.version_info < (3, 6), "format string literals require 3.6 or higher")
-    def test_fstring(self) -> None:
-        src_code = """e = ExpressionScalar('2.0'); \
-        self.assertEqual( f'{e}', str(e) ); \
-        self.assertEqual( f'{e:.2f}', '%.2f' % e)
-        """
-        exec(src_code)
-        
-    def test_evaluate_numeric(self) -> None:
-        e = ExpressionScalar('a * b + c')
-        params = {
-            'a': 2,
-            'b': 1.5,
-            'c': -7
-        }
-        self.assertEqual(2 * 1.5 - 7, e.evaluate_numeric(**params))
-
-        with self.assertRaises(NonNumericEvaluation):
-            params['a'] = sympify('h')
-            e.evaluate_numeric(**params)
-
-    def test_evaluate_numpy(self):
-        e = ExpressionScalar('a * b + c')
-        params = {
-            'a': 2*np.ones(4),
-            'b': 1.5*np.ones(4),
-            'c': -7*np.ones(4)
-        }
-        np.testing.assert_equal((2 * 1.5 - 7) * np.ones(4), e.evaluate_numeric(**params))
-
-        e = ExpressionScalar('a * b + c')
-        params = {
-            'a': np.array(2),
-            'b': np.array(1.5),
-            'c': np.array(-7)
-        }
-        np.testing.assert_equal((2 * 1.5 - 7), e.evaluate_numeric(**params))
-
-    def test_indexing(self):
-        e = ExpressionScalar('a[i] * c')
-
-        params = {
-            'a': np.array([1, 2, 3]),
-            'i': 1,
-            'c': 2
-        }
-
-        self.assertEqual(e.evaluate_numeric(**params), 2 * 2)
-        params['a'] = [1, 2, 3]
-        self.assertEqual(e.evaluate_numeric(**params), 2 * 2)
-        params['a'] = np.array([[1, 2, 3], [4, 5, 6]])
-        np.testing.assert_equal(e.evaluate_numeric(**params), 2 * np.array([4, 5, 6]))
-
-    def test_partial_evaluation(self) -> None:
-        e = ExpressionScalar('a * c')
-        params = {'c': 5.5}
-        evaluated = e.evaluate_symbolic(params)
-        expected = ExpressionScalar('a * 5.5')
-        self.assertEqual(expected.underlying_expression, evaluated.underlying_expression)
-
-    def test_partial_evaluation_vectorized(self) -> None:
-        e = ExpressionScalar('a[i] * c')
-
-        params = {
-            'c': np.array([[1, 2], [3, 4]])
-        }
-
-        evaluated = e.evaluate_symbolic(params)
-        expected = ExpressionVector([['a[i] * 1', 'a[i] * 2'], ['a[i] * 3', 'a[i] * 4']])
-
-        np.testing.assert_equal(evaluated.underlying_expression, expected.underlying_expression)
-
-    def test_evaluate_numeric_without_numpy(self):
-        e = Expression('a * b + c')
-
-        params = {
-            'a': 2,
-            'b': 1.5,
-            'c': -7
-        }
-        self.assertEqual(2 * 1.5 - 7, e.evaluate_numeric(**params))
-
-        params = {
-            'a': 2j,
-            'b': 1.5,
-            'c': -7
-        }
-        self.assertEqual(2j * 1.5 - 7, e.evaluate_numeric(**params))
-
-        params = {
-            'a': 2,
-            'b': 6,
-            'c': -7
-        }
-        self.assertEqual(2 * 6 - 7, e.evaluate_numeric(**params))
-
-        params = {
-            'a': 2,
-            'b': sympify('k'),
-            'c': -7
-        }
-        with self.assertRaises(NonNumericEvaluation):
-            e.evaluate_numeric(**params)
-
-    def test_evaluate_symbolic(self):
-        e = ExpressionScalar('a * b + c')
-        params = {
-            'a': 'd',
-            'c': -7
-        }
-        result = e.evaluate_symbolic(params)
-        expected = ExpressionScalar('d*b-7')
-        self.assertEqual(result, expected)
-
-    def test_variables(self) -> None:
-        e = ExpressionScalar('4 ** pi + x * foo')
-        expected = sorted(['foo', 'x'])
-        received = sorted(e.variables)
-        self.assertEqual(expected, received)
-
-    def test_variables_indexed(self):
-        e = ExpressionScalar('a[i] * c')
-        expected = sorted(['a', 'i', 'c'])
-        received = sorted(e.variables)
-        self.assertEqual(expected, received)
-
-    def test_evaluate_variable_missing(self) -> None:
-        e = ExpressionScalar('a * b + c')
-        params = {
-            'b': 1.5
-        }
-        with self.assertRaises(ExpressionVariableMissingException):
-            e.evaluate_numeric(**params)
-
-    def test_repr(self):
-        s = 'a    *    b'
-        e = ExpressionScalar(s)
-        self.assertEqual("ExpressionScalar('a    *    b')", repr(e))
-
-    def test_repr_original_expression_is_sympy(self):
-        # in this case we test that we get the original expression back if we do
-        # eval(repr(e))
-
-        org = sympy.sympify(3.1415)
-        e = ExpressionScalar(org)
-        self.assertEqual(e, eval(repr(e)))
-
-        org = sympy.abc.a * sympy.abc.b
-        e = ExpressionScalar(org)
-        self.assertEqual(e, eval(repr(e)))
-
-        org = sympy.sympify('3/17')
-        e = ExpressionScalar(org)
-        self.assertEqual(e, eval(repr(e)))
-
-    def test_str(self):
-        s = 'a    *    b'
-        e = ExpressionScalar(s)
-        self.assertEqual('a*b', str(e))
-
-    def test_original_expression(self):
-        s = 'a    *    b'
-        self.assertEqual(ExpressionScalar(s).original_expression, s)
-
-    def test_hash(self):
-        expected = {ExpressionScalar(2), ExpressionScalar('a')}
-        sequence = [ExpressionScalar(2), ExpressionScalar('a'), ExpressionScalar(2), ExpressionScalar('a')]
-        self.assertEqual(expected, set(sequence))
-
-    def test_undefined_comparison(self):
-        valued = ExpressionScalar(2)
-        unknown = ExpressionScalar('a')
-
-        self.assertIsNone(unknown < 0)
-        self.assertIsNone(unknown > 0)
-        self.assertIsNone(unknown >= 0)
-        self.assertIsNone(unknown <= 0)
-        self.assertFalse(unknown == 0)
-
-        self.assertIsNone(0 < unknown)
-        self.assertIsNone(0 > unknown)
-        self.assertIsNone(0 <= unknown)
-        self.assertIsNone(0 >= unknown)
-        self.assertFalse(0 == unknown)
-
-        self.assertIsNone(unknown < valued)
-        self.assertIsNone(unknown > valued)
-        self.assertIsNone(unknown >= valued)
-        self.assertIsNone(unknown <= valued)
-        self.assertFalse(unknown == valued)
-
-        valued, unknown = unknown, valued
-        self.assertIsNone(unknown < valued)
-        self.assertIsNone(unknown > valued)
-        self.assertIsNone(unknown >= valued)
-        self.assertIsNone(unknown <= valued)
-        self.assertFalse(unknown == valued)
-        valued, unknown = unknown, valued
-
-        self.assertFalse(unknown == valued)
-
-    def test_defined_comparison(self):
-        small = ExpressionScalar(2)
-        large = ExpressionScalar(3)
-
-        self.assertIs(small < small, False)
-        self.assertIs(small > small, False)
-        self.assertIs(small <= small, True)
-        self.assertIs(small >= small, True)
-        self.assertIs(small == small, True)
-
-        self.assertIs(small < large, True)
-        self.assertIs(small > large, False)
-        self.assertIs(small <= large, True)
-        self.assertIs(small >= large, False)
-        self.assertIs(small == large, False)
-
-        self.assertIs(large < small, False)
-        self.assertIs(large > small, True)
-        self.assertIs(large <= small, False)
-        self.assertIs(large >= small, True)
-        self.assertIs(large == small, False)
-
-    def test_number_comparison(self):
-        valued = ExpressionScalar(2)
-
-        self.assertIs(valued < 3, True)
-        self.assertIs(valued > 3, False)
-        self.assertIs(valued <= 3, True)
-        self.assertIs(valued >= 3, False)
-
-        self.assertIs(valued == 3, False)
-        self.assertIs(valued == 2, True)
-        self.assertIs(3 == valued, False)
-        self.assertIs(2 == valued, True)
-
-        self.assertIs(3 < valued, False)
-        self.assertIs(3 > valued, True)
-        self.assertIs(3 <= valued, False)
-        self.assertIs(3 >= valued, True)
-
-    def assertExpressionEqual(self, lhs: Expression, rhs: Expression):
-        self.assertTrue(bool(Eq(lhs, rhs)), '{} and {} are not equal'.format(lhs, rhs))
-
-    def test_number_math(self):
-        a = ExpressionScalar('a')
-        b = 3.3
-
-        self.assertExpressionEqual(a + b, b + a)
-        self.assertExpressionEqual(a - b, -(b - a))
-        self.assertExpressionEqual(a * b, b * a)
-        self.assertExpressionEqual(a / b, 1 / (b / a))
-
-    def test_symbolic_math(self):
-        a = ExpressionScalar('a')
-        b = ExpressionScalar('b')
-
-        self.assertExpressionEqual(a + b, b + a)
-        self.assertExpressionEqual(a - b, -(b - a))
-        self.assertExpressionEqual(a * b, b * a)
-        self.assertExpressionEqual(a / b, 1 / (b / a))
-
-    def test_sympy_math(self):
-        a = ExpressionScalar('a')
-        b = sympify('b')
-
-        self.assertExpressionEqual(a + b, b + a)
-        self.assertExpressionEqual(a - b, -(b - a))
-        self.assertExpressionEqual(a * b, b * a)
-        self.assertExpressionEqual(a / b, 1 / (b / a))
-
-    def test_is_nan(self):
-        self.assertTrue(ExpressionScalar('nan').is_nan())
-        self.assertTrue(ExpressionScalar('0./0.').is_nan())
-
-        self.assertFalse(ExpressionScalar(456).is_nan())
-
-    def test_special_function_numeric_evaluation(self):
-        expr = Expression('erfc(t)')
-        data = [-1., 0., 1.]
-        expected = np.array([1.84270079, 1., 0.15729921])
-        result = expr.evaluate_numeric(t=data)
-
-        np.testing.assert_allclose(expected, result)
-
-    def test_evaluate_with_exact_rationals(self):
-        expr = ExpressionScalar('1 / 3')
-        self.assertEqual(TimeType.from_fraction(1, 3), expr.evaluate_with_exact_rationals({}))
-
-        expr = ExpressionScalar('a * (1 / 3)')
-        self.assertEqual(TimeType.from_fraction(2, 3), expr.evaluate_with_exact_rationals({'a': 2}))
-
-        expr = ExpressionScalar('dot(a, b) * (1 / 3)')
-        self.assertEqual(TimeType.from_fraction(10, 3),
-                         expr.evaluate_with_exact_rationals({'a': [2, 2], 'b': [1, 4]}))
-
-    def test_pickle(self):
-        expr = ExpressionScalar('1 / a')
-        # populate lambdified
-        expr.evaluate_in_scope({'a': 7})
-        dumped = pickle.dumps(expr)
-        loaded = pickle.loads(dumped)
-        self.assertEqual(expr, loaded)
-
-
-class ExpressionExceptionTests(unittest.TestCase):
-    def test_expression_variable_missing(self):
-        variable = 's'
-        expression = ExpressionScalar('s*t')
-
-        self.assertEqual(str(ExpressionVariableMissingException(variable, expression)),
-                         "Could not evaluate <s*t>: A value for variable 
 is missing!")
-
-    def test_non_numeric_evaluation(self):
-        expression = ExpressionScalar('a*b')
-        call_arguments = dict()
-
-        expected = "The result of evaluate_numeric is of type {} " \
-                   "which is not a number".format(float)
-        self.assertEqual(str(NonNumericEvaluation(expression, 1., call_arguments)), expected)
-
-        expected = "The result of evaluate_numeric is of type {} " \
-                   "which is not a number".format(np.zeros(1).dtype)
-        self.assertEqual(str(NonNumericEvaluation(expression, np.zeros(1), call_arguments)), expected)
+import pickle
+import unittest
+import sys
+
+import numpy as np
+import sympy.abc
+from sympy import sympify, Eq
+
+from qupulse.expressions.sympy import Expression, ExpressionScalar, ExpressionVector
+from qupulse.expressions import ExpressionVariableMissingException, NonNumericEvaluation
+from qupulse.utils.types import TimeType
+
+class ExpressionTests(unittest.TestCase):
+    def test_make(self):
+        self.assertTrue(Expression.make('a') == 'a')
+        self.assertTrue(Expression.make('a + b') == 'a + b')
+        self.assertTrue(Expression.make(9) == 9)
+
+        self.assertIsInstance(Expression.make([1, 'a']), ExpressionVector)
+
+        self.assertIsInstance(ExpressionScalar.make('a'), ExpressionScalar)
+        self.assertIsInstance(ExpressionVector.make(['a']), ExpressionVector)
+
+
+class ExpressionVectorTests(unittest.TestCase):
+    def test_evaluate_numeric(self) -> None:
+        e = ExpressionVector(['a * b + c', 'a + d'])
+        params = {
+            'a': 2,
+            'b': 1.5,
+            'c': -7,
+            'd': 9
+        }
+        np.testing.assert_equal(np.array([2 * 1.5 - 7, 2 + 9]),
+                                e.evaluate_numeric(**params))
+
+        with self.assertRaises(NonNumericEvaluation):
+            params['a'] = sympify('h')
+            e.evaluate_numeric(**params)
+
+    def test_evaluate_numeric_2d(self) -> None:
+        e = ExpressionVector([['a * b + c', 'a + d'], ['a', 3]])
+        params = {
+            'a': 2,
+            'b': 1.5,
+            'c': -7,
+            'd': 9
+        }
+        np.testing.assert_equal(np.array([[2 * 1.5 - 7, 2 + 9], [2, 3]]),
+                                e.evaluate_numeric(**params))
+
+        with self.assertRaises(NonNumericEvaluation):
+            params['a'] = sympify('h')
+            e.evaluate_numeric(**params)
+
+    def test_partial_evaluation(self):
+        e = ExpressionVector(['a * b + c', 'a + d'])
+
+        params = {
+            'a': 2,
+            'b': 1.5,
+            'c': -7
+        }
+
+        expected = ExpressionVector([2 * 1.5 - 7, '2 + d'])
+        evaluated = e.evaluate_symbolic(params)
+
+        np.testing.assert_equal(evaluated.underlying_expression, expected.underlying_expression)
+
+    def test_symbolic_evaluation(self):
+        e = ExpressionVector([['a * b + c', 'a + d'], ['a', 3]])
+        params = {
+            'a': 2,
+            'b': 1.5,
+            'c': -7,
+            'd': 9
+        }
+
+        expected = ExpressionVector([[2 * 1.5 - 7, 2 + 9], [2, 3]])
+        evaluated = e.evaluate_symbolic(params)
+
+        np.testing.assert_equal(evaluated.underlying_expression, expected.underlying_expression)
+
+    def test_numeric_expression(self):
+        numbers = np.linspace(1, 2, num=5)
+
+        e = ExpressionVector(numbers)
+
+        np.testing.assert_equal(e.underlying_expression, numbers)
+
+    def test_eq(self):
+        e1 = ExpressionVector([1, 2])
+        e2 = ExpressionVector(['1', '2'])
+        e3 = ExpressionVector(['1', 'a'])
+        e4 = ExpressionVector([1, 'a'])
+        e5 = ExpressionVector([1, 'a', 3])
+        e6 = ExpressionVector([1, 1, '1'])
+        e7 = ExpressionVector(['a'])
+
+        self.assertEqual(e1, e2)
+        self.assertEqual(e3, e4)
+        self.assertNotEqual(e4, e5)
+
+        self.assertEqual(e1, [1, 2])
+        self.assertNotEqual(e6, 1)
+        self.assertEqual(e7, ExpressionScalar('a'))
+
+    def test_hash(self):
+        e1 = ExpressionVector([1, 2])
+        e2 = ExpressionVector(['1', '2'])
+        e7 = ExpressionVector(['a'])
+
+        s = ExpressionScalar('a')
+        self.assertEqual({e1, e7}, {e1, e2, e7, s})
+
+    def test_pickle(self):
+        expr = ExpressionVector([1, 'a + 5', 3])
+        # populate lambdified
+        expr.evaluate_in_scope({'a': 3})
+        dumped = pickle.dumps(expr)
+        loaded = pickle.loads(dumped)
+        self.assertEqual(expr, loaded)
+
+
+class ExpressionScalarTests(unittest.TestCase):
+    def test_format(self):
+        expr = ExpressionScalar('17')
+        e_format = '{:.4e}'.format(expr)
+        self.assertEqual(e_format, "1.7000e+01")
+
+        empty_format = "{}".format(expr)
+        self.assertEqual(empty_format, '17')
+
+        expr_with_var = ExpressionScalar('17*a')
+        with self.assertRaises(TypeError):
+            # throw error on implicit float cast
+            '{:.4e}'.format(expr_with_var)
+
+        empty_format = "{}".format(expr_with_var)
+        self.assertEqual(empty_format, '17*a')
+
+    @unittest.skipIf(sys.version_info < (3, 6), "format string literals require 3.6 or higher")
+    def test_fstring(self) -> None:
+        src_code = """e = ExpressionScalar('2.0'); \
+        self.assertEqual( f'{e}', str(e) ); \
+        self.assertEqual( f'{e:.2f}', '%.2f' % e)
+        """
+        exec(src_code)
+        
+    def test_evaluate_numeric(self) -> None:
+        e = ExpressionScalar('a * b + c')
+        params = {
+            'a': 2,
+            'b': 1.5,
+            'c': -7
+        }
+        self.assertEqual(2 * 1.5 - 7, e.evaluate_numeric(**params))
+
+        with self.assertRaises(NonNumericEvaluation):
+            params['a'] = sympify('h')
+            e.evaluate_numeric(**params)
+
+    def test_evaluate_numpy(self):
+        e = ExpressionScalar('a * b + c')
+        params = {
+            'a': 2*np.ones(4),
+            'b': 1.5*np.ones(4),
+            'c': -7*np.ones(4)
+        }
+        np.testing.assert_equal((2 * 1.5 - 7) * np.ones(4), e.evaluate_numeric(**params))
+
+        e = ExpressionScalar('a * b + c')
+        params = {
+            'a': np.array(2),
+            'b': np.array(1.5),
+            'c': np.array(-7)
+        }
+        np.testing.assert_equal((2 * 1.5 - 7), e.evaluate_numeric(**params))
+
+    def test_indexing(self):
+        e = ExpressionScalar('a[i] * c')
+
+        params = {
+            'a': np.array([1, 2, 3]),
+            'i': 1,
+            'c': 2
+        }
+
+        self.assertEqual(e.evaluate_numeric(**params), 2 * 2)
+        params['a'] = [1, 2, 3]
+        self.assertEqual(e.evaluate_numeric(**params), 2 * 2)
+        params['a'] = np.array([[1, 2, 3], [4, 5, 6]])
+        np.testing.assert_equal(e.evaluate_numeric(**params), 2 * np.array([4, 5, 6]))
+
+    def test_partial_evaluation(self) -> None:
+        e = ExpressionScalar('a * c')
+        params = {'c': 5.5}
+        evaluated = e.evaluate_symbolic(params)
+        expected = ExpressionScalar('a * 5.5')
+        self.assertEqual(expected.underlying_expression, evaluated.underlying_expression)
+
+    def test_partial_evaluation_vectorized(self) -> None:
+        e = ExpressionScalar('a[i] * c')
+
+        params = {
+            'c': np.array([[1, 2], [3, 4]])
+        }
+
+        evaluated = e.evaluate_symbolic(params)
+        expected = ExpressionVector([['a[i] * 1', 'a[i] * 2'], ['a[i] * 3', 'a[i] * 4']])
+
+        np.testing.assert_equal(evaluated.underlying_expression, expected.underlying_expression)
+
+    def test_evaluate_numeric_without_numpy(self):
+        e = Expression('a * b + c')
+
+        params = {
+            'a': 2,
+            'b': 1.5,
+            'c': -7
+        }
+        self.assertEqual(2 * 1.5 - 7, e.evaluate_numeric(**params))
+
+        params = {
+            'a': 2j,
+            'b': 1.5,
+            'c': -7
+        }
+        self.assertEqual(2j * 1.5 - 7, e.evaluate_numeric(**params))
+
+        params = {
+            'a': 2,
+            'b': 6,
+            'c': -7
+        }
+        self.assertEqual(2 * 6 - 7, e.evaluate_numeric(**params))
+
+        params = {
+            'a': 2,
+            'b': sympify('k'),
+            'c': -7
+        }
+        with self.assertRaises(NonNumericEvaluation):
+            e.evaluate_numeric(**params)
+
+    def test_evaluate_symbolic(self):
+        e = ExpressionScalar('a * b + c')
+        params = {
+            'a': 'd',
+            'c': -7
+        }
+        result = e.evaluate_symbolic(params)
+        expected = ExpressionScalar('d*b-7')
+        self.assertEqual(result, expected)
+
+    def test_variables(self) -> None:
+        e = ExpressionScalar('4 ** pi + x * foo')
+        expected = sorted(['foo', 'x'])
+        received = sorted(e.variables)
+        self.assertEqual(expected, received)
+
+    def test_variables_indexed(self):
+        e = ExpressionScalar('a[i] * c')
+        expected = sorted(['a', 'i', 'c'])
+        received = sorted(e.variables)
+        self.assertEqual(expected, received)
+
+    def test_evaluate_variable_missing(self) -> None:
+        e = ExpressionScalar('a * b + c')
+        params = {
+            'b': 1.5
+        }
+        with self.assertRaises(ExpressionVariableMissingException):
+            e.evaluate_numeric(**params)
+
+    def test_repr(self):
+        s = 'a    *    b'
+        e = ExpressionScalar(s)
+        self.assertEqual("ExpressionScalar('a    *    b')", repr(e))
+
+    def test_repr_original_expression_is_sympy(self):
+        # in this case we test that we get the original expression back if we do
+        # eval(repr(e))
+
+        org = sympy.sympify(3.1415)
+        e = ExpressionScalar(org)
+        self.assertEqual(e, eval(repr(e)))
+
+        org = sympy.abc.a * sympy.abc.b
+        e = ExpressionScalar(org)
+        self.assertEqual(e, eval(repr(e)))
+
+        org = sympy.sympify('3/17')
+        e = ExpressionScalar(org)
+        self.assertEqual(e, eval(repr(e)))
+
+    def test_str(self):
+        s = 'a    *    b'
+        e = ExpressionScalar(s)
+        self.assertEqual('a*b', str(e))
+
+    def test_original_expression(self):
+        s = 'a    *    b'
+        self.assertEqual(ExpressionScalar(s).original_expression, s)
+
+    def test_hash(self):
+        expected = {ExpressionScalar(2), ExpressionScalar('a')}
+        sequence = [ExpressionScalar(2), ExpressionScalar('a'), ExpressionScalar(2), ExpressionScalar('a')]
+        self.assertEqual(expected, set(sequence))
+
+    def test_undefined_comparison(self):
+        valued = ExpressionScalar(2)
+        unknown = ExpressionScalar('a')
+
+        self.assertIsNone(unknown < 0)
+        self.assertIsNone(unknown > 0)
+        self.assertIsNone(unknown >= 0)
+        self.assertIsNone(unknown <= 0)
+        self.assertFalse(unknown == 0)
+
+        self.assertIsNone(0 < unknown)
+        self.assertIsNone(0 > unknown)
+        self.assertIsNone(0 <= unknown)
+        self.assertIsNone(0 >= unknown)
+        self.assertFalse(0 == unknown)
+
+        self.assertIsNone(unknown < valued)
+        self.assertIsNone(unknown > valued)
+        self.assertIsNone(unknown >= valued)
+        self.assertIsNone(unknown <= valued)
+        self.assertFalse(unknown == valued)
+
+        valued, unknown = unknown, valued
+        self.assertIsNone(unknown < valued)
+        self.assertIsNone(unknown > valued)
+        self.assertIsNone(unknown >= valued)
+        self.assertIsNone(unknown <= valued)
+        self.assertFalse(unknown == valued)
+        valued, unknown = unknown, valued
+
+        self.assertFalse(unknown == valued)
+
+    def test_defined_comparison(self):
+        small = ExpressionScalar(2)
+        large = ExpressionScalar(3)
+
+        self.assertIs(small < small, False)
+        self.assertIs(small > small, False)
+        self.assertIs(small <= small, True)
+        self.assertIs(small >= small, True)
+        self.assertIs(small == small, True)
+
+        self.assertIs(small < large, True)
+        self.assertIs(small > large, False)
+        self.assertIs(small <= large, True)
+        self.assertIs(small >= large, False)
+        self.assertIs(small == large, False)
+
+        self.assertIs(large < small, False)
+        self.assertIs(large > small, True)
+        self.assertIs(large <= small, False)
+        self.assertIs(large >= small, True)
+        self.assertIs(large == small, False)
+
+    def test_number_comparison(self):
+        valued = ExpressionScalar(2)
+
+        self.assertIs(valued < 3, True)
+        self.assertIs(valued > 3, False)
+        self.assertIs(valued <= 3, True)
+        self.assertIs(valued >= 3, False)
+
+        self.assertIs(valued == 3, False)
+        self.assertIs(valued == 2, True)
+        self.assertIs(3 == valued, False)
+        self.assertIs(2 == valued, True)
+
+        self.assertIs(3 < valued, False)
+        self.assertIs(3 > valued, True)
+        self.assertIs(3 <= valued, False)
+        self.assertIs(3 >= valued, True)
+
+    def assertExpressionEqual(self, lhs: Expression, rhs: Expression):
+        self.assertTrue(bool(Eq(lhs, rhs)), '{} and {} are not equal'.format(lhs, rhs))
+
+    def test_number_math(self):
+        a = ExpressionScalar('a')
+        b = 3.3
+
+        self.assertExpressionEqual(a + b, b + a)
+        self.assertExpressionEqual(a - b, -(b - a))
+        self.assertExpressionEqual(a * b, b * a)
+        self.assertExpressionEqual(a / b, 1 / (b / a))
+        self.assertExpressionEqual(a // 3, ExpressionScalar('floor(a / 3)'))
+        self.assertExpressionEqual(a // 3, ExpressionScalar('a // 3'))
+        self.assertExpressionEqual(3 // a, ExpressionScalar('floor(3 / a)'))
+
+    def test_symbolic_math(self):
+        a = ExpressionScalar('a')
+        b = ExpressionScalar('b')
+
+        self.assertExpressionEqual(a + b, b + a)
+        self.assertExpressionEqual(a - b, -(b - a))
+        self.assertExpressionEqual(a * b, b * a)
+        self.assertExpressionEqual(a / b, 1 / (b / a))
+        self.assertExpressionEqual(a // b, ExpressionScalar('floor(a / b)'))
+
+    def test_sympy_math(self):
+        a = ExpressionScalar('a')
+        b = sympify('b')
+
+        self.assertExpressionEqual(a + b, b + a)
+        self.assertExpressionEqual(a - b, -(b - a))
+        self.assertExpressionEqual(a * b, b * a)
+        self.assertExpressionEqual(a / b, 1 / (b / a))
+
+    def test_is_nan(self):
+        self.assertTrue(ExpressionScalar('nan').is_nan())
+        self.assertTrue(ExpressionScalar('0./0.').is_nan())
+
+        self.assertFalse(ExpressionScalar(456).is_nan())
+
+    def test_special_function_numeric_evaluation(self):
+        expr = Expression('erfc(t)')
+        data = [-1., 0., 1.]
+        expected = np.array([1.84270079, 1., 0.15729921])
+        result = expr.evaluate_numeric(t=data)
+
+        np.testing.assert_allclose(expected, result)
+
+    def test_evaluate_with_exact_rationals(self):
+        expr = ExpressionScalar('1 / 3')
+        self.assertEqual(TimeType.from_fraction(1, 3), expr.evaluate_with_exact_rationals({}))
+
+        expr = ExpressionScalar('a * (1 / 3)')
+        self.assertEqual(TimeType.from_fraction(2, 3), expr.evaluate_with_exact_rationals({'a': 2}))
+
+        expr = ExpressionScalar('dot(a, b) * (1 / 3)')
+        self.assertEqual(TimeType.from_fraction(10, 3),
+                         expr.evaluate_with_exact_rationals({'a': [2, 2], 'b': [1, 4]}))
+
+    def test_pickle(self):
+        expr = ExpressionScalar('1 / a')
+        # populate lambdified
+        expr.evaluate_in_scope({'a': 7})
+        dumped = pickle.dumps(expr)
+        loaded = pickle.loads(dumped)
+        self.assertEqual(expr, loaded)
+
+
+class ExpressionExceptionTests(unittest.TestCase):
+    def test_expression_variable_missing(self):
+        variable = 's'
+        expression = ExpressionScalar('s*t')
+
+        self.assertEqual(str(ExpressionVariableMissingException(variable, expression)),
+                         "Could not evaluate <s*t>: A value for variable 
 is missing!")
+
+    def test_non_numeric_evaluation(self):
+        expression = ExpressionScalar('a*b')
+        call_arguments = dict()
+
+        expected = "The result of evaluate_numeric is of type {} " \
+                   "which is not a number".format(float)
+        self.assertEqual(str(NonNumericEvaluation(expression, 1., call_arguments)), expected)
+
+        expected = "The result of evaluate_numeric is of type {} " \
+                   "which is not a number".format(np.zeros(1).dtype)
+        self.assertEqual(str(NonNumericEvaluation(expression, np.zeros(1), call_arguments)), expected)
diff --git a/tests/hardware/alazar_tests.py b/tests/hardware/alazar_tests.py
index c6e7d032d..034f05254 100644
--- a/tests/hardware/alazar_tests.py
+++ b/tests/hardware/alazar_tests.py
@@ -6,7 +6,7 @@
 from ..hardware import *
 from qupulse.hardware.dacs.alazar import AlazarCard, AlazarProgram
 from qupulse.utils.types import TimeType
-
+from qupulse.utils.performance import WindowOverlapWarning
 
 class AlazarProgramTest(unittest.TestCase):
     def setUp(self) -> None:
@@ -112,7 +112,7 @@ def test_make_mask(self):
         with self.assertRaises(KeyError):
             card._make_mask('N', begins, lengths)
 
-        with self.assertRaises(ValueError):
+        with self.assertWarns(WindowOverlapWarning):
             card._make_mask('M', begins, lengths*3)
 
         mask = card._make_mask('M', begins, lengths)
diff --git a/tests/hardware/base_tests.py b/tests/hardware/base_tests.py
index a3f029618..137fd071c 100644
--- a/tests/hardware/base_tests.py
+++ b/tests/hardware/base_tests.py
@@ -5,7 +5,7 @@
 import numpy as np
 
 from qupulse.utils.types import TimeType
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 from qupulse.hardware.awgs.base import ProgramEntry
 
 from tests.pulses.sequencing_dummies import DummyWaveform
@@ -43,7 +43,7 @@ def test_init(self):
         expected_waveforms = OrderedDict(zip(self.waveforms, sampled))
 
         with mock.patch.object(ProgramEntry, '_sample_waveforms', return_value=sampled) as sample_waveforms:
-            entry = ProgramEntry(loop=self.loop,
+            entry = ProgramEntry(program=self.loop,
                                  channels=self.channels,
                                  markers=self.marker,
                                  amplitudes=self.amplitudes,
@@ -56,7 +56,7 @@ def test_init(self):
             sample_waveforms.assert_not_called()
 
         with mock.patch.object(ProgramEntry, '_sample_waveforms', return_value=sampled) as sample_waveforms:
-            entry = ProgramEntry(loop=self.loop,
+            entry = ProgramEntry(program=self.loop,
                                  channels=self.channels,
                                  markers=self.marker,
                                  amplitudes=self.amplitudes,
@@ -68,7 +68,7 @@ def test_init(self):
             sample_waveforms.assert_called_once_with(expected_default)
 
         with mock.patch.object(ProgramEntry, '_sample_waveforms', return_value=sampled[:1]) as sample_waveforms:
-            entry = ProgramEntry(loop=self.loop,
+            entry = ProgramEntry(program=self.loop,
                                  channels=self.channels,
                                  markers=self.marker,
                                  amplitudes=self.amplitudes,
@@ -89,7 +89,7 @@ def test_sample_waveforms(self):
             ((self.sampled[1]['A'], empty_ch, 2.*(self.sampled[1]['C'] - 0.1)), (empty_m, self.sampled[1]['M'] != 0))
         ]
 
-        entry = ProgramEntry(loop=self.loop,
+        entry = ProgramEntry(program=self.loop,
                              channels=self.channels,
                              markers=self.marker,
                              amplitudes=self.amplitudes,
diff --git a/tests/hardware/dummy_devices.py b/tests/hardware/dummy_devices.py
index a92ce1282..60dee6f9e 100644
--- a/tests/hardware/dummy_devices.py
+++ b/tests/hardware/dummy_devices.py
@@ -1,126 +1,2 @@
-from typing import Tuple, Set, Dict
-from collections import deque
-
-
-from qupulse.hardware.awgs.base import AWG, ProgramOverwriteException
-from qupulse.hardware.dacs import DAC
-
-class DummyDAC(DAC):
-    def __init__(self):
-        self._measurement_windows = dict()
-        self._operations = dict()
-        self.measured_data = deque([])
-        self._meas_masks = {}
-        self._armed_program = None
-
-    @property
-    def armed_program(self):
-        return self._armed_program
-
-    def register_measurement_windows(self, program_name: str, windows: Dict[str, Tuple['numpy.ndarray',
-                                                                                       'numpy.ndarray']]):
-        self._measurement_windows[program_name] = windows
-
-    def register_operations(self, program_name: str, operations):
-        self._operations[program_name] = operations
-
-    def arm_program(self, program_name: str):
-        self._armed_program = program_name
-
-    def delete_program(self, program_name):
-        if program_name in self._operations:
-            self._operations.pop(program_name)
-        if program_name in self._measurement_windows:
-            self._measurement_windows.pop(program_name)
-
-    def clear(self) -> None:
-        self._measurement_windows = dict()
-        self._operations = dict()
-        self._armed_program = None
-
-    def measure_program(self, channels):
-        return self.measured_data.pop()
-
-    def set_measurement_mask(self, program_name, mask_name, begins, lengths) -> Tuple['numpy.ndarray', 'numpy.ndarray']:
-        self._meas_masks.setdefault(program_name, {})[mask_name] = (begins, lengths)
-        return begins, lengths
-
-
-class DummyAWG(AWG):
-    """Dummy AWG for debugging purposes."""
-
-    def __init__(self,
-                 memory: int=100,
-                 sample_rate: float=10,
-                 output_range: Tuple[float, float]=(-5, 5),
-                 num_channels: int=1,
-                 num_markers: int=1) -> None:
-        """Create a new DummyAWG instance.
-
-        Args:
-            memory (int): Available memory slots for waveforms. (default = 100)
-            sample_rate (float): The sample rate of the dummy. (default = 10)
-            output_range (float, float): A (min,max)-tuple of possible output values.
-                (default = (-5,5)).
-        """
-        super().__init__(identifier="DummyAWG{0}".format(id(self)))
-
-        self._programs = {} # contains program names and programs
-        self._sample_rate = sample_rate
-        self._output_range = output_range
-        self._num_channels = num_channels
-        self._num_markers = num_markers
-        self._channels = ('default',)
-        self._armed = None
-
-        # todo [2018-06-14]: The following attributes (and thus the memory argument) are never used. Remove?
-        self._waveform_memory = [None for i in range(memory)]
-        self._waveform_indices = {}  # dict that maps from waveform hash to memory index
-        self._program_wfs = {}  # contains program names and necessary waveforms indices
-
-    def set_volatile_parameters(self, program_name: str, parameters):
-        raise NotImplementedError()
-
-    def upload(self, name, program, channels, markers, voltage_transformation, force=False) -> None:
-        if name in self.programs:
-            if not force:
-                raise ProgramOverwriteException(name)
-            else:
-                self.remove(name)
-                self.upload(name, program)
-        else:
-            self._programs[name] = (program, channels, markers, voltage_transformation)
-
-    def remove(self, name) -> None:
-        if name in self.programs:
-            self._programs.pop(name)
-
-    def clear(self) -> None:
-        self._programs = {}
-
-    def arm(self, name: str) -> None:
-        self._armed = name
-
-    @property
-    def programs(self) -> Set[str]:
-        return set(self._programs.keys())
-
-    @property
-    def output_range(self) -> Tuple[float, float]:
-        return self._output_range
-
-    @property
-    def identifier(self) -> str:
-        return "DummyAWG{0}".format(id(self))
-
-    @property
-    def sample_rate(self) -> float:
-        return self._sample_rate
-
-    @property
-    def num_channels(self):
-        return self._num_channels
-
-    @property
-    def num_markers(self):
-        return self._num_markers
\ No newline at end of file
+from qupulse.hardware.dacs.dummy import DummyDAC
+from qupulse.hardware.awgs.dummy import DummyAWG
diff --git a/tests/hardware/feature_awg/awg_new_driver_base_tests.py b/tests/hardware/feature_awg/awg_new_driver_base_tests.py
index 12070e1c1..1845ab1e0 100644
--- a/tests/hardware/feature_awg/awg_new_driver_base_tests.py
+++ b/tests/hardware/feature_awg/awg_new_driver_base_tests.py
@@ -3,7 +3,7 @@
 import warnings
 
 from qupulse import ChannelID
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 from qupulse.hardware.feature_awg import channel_tuple_wrapper
 from qupulse.hardware.feature_awg.base import AWGDevice, AWGChannel, AWGChannelTuple, AWGMarkerChannel
 from qupulse.hardware.feature_awg.features import ChannelSynchronization, ProgramManagement, VoltageRange, \
diff --git a/tests/hardware/setup_tests.py b/tests/hardware/setup_tests.py
index d36467e23..fd3ba3c17 100644
--- a/tests/hardware/setup_tests.py
+++ b/tests/hardware/setup_tests.py
@@ -4,7 +4,7 @@
 import numpy as np
 
 from qupulse.hardware.setup import HardwareSetup, PlaybackChannel, MarkerChannel, MeasurementMask
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 
 from tests.pulses.sequencing_dummies import DummyWaveform
 
diff --git a/tests/hardware/tabor_dummy_based_tests.py b/tests/hardware/tabor_dummy_based_tests.py
index 4bc3f84fa..98588b5ed 100644
--- a/tests/hardware/tabor_dummy_based_tests.py
+++ b/tests/hardware/tabor_dummy_based_tests.py
@@ -10,7 +10,7 @@
 
 from qupulse.hardware.awgs.base import AWGAmplitudeOffsetHandling
 from qupulse.utils.types import TimeType
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 from qupulse._program.tabor import TableDescription, TimeType, TableEntry, TaborSegment, TaborProgram,\
     make_combined_wave, TaborSequencing
 from qupulse._program.waveforms import ConstantWaveform
@@ -461,97 +461,6 @@ def test_upload_offset_handling(self):
                                                            voltage_transformations=test_transform)
                 self.assertEqual([], offset_mock.call_args_list)
 
-    def test_find_place_for_segments_in_memory(self):
-        def hash_based_on_dir(ch):
-            hash_list = []
-            for d in dir(ch):
-                o = getattr(ch, d)
-                if isinstance(o, np.ndarray):
-                    hash_list.append(hash(o.tobytes()))
-                else:
-                    try:
-                        hash_list.append(hash(o))
-                    except TypeError:
-                        pass
-            return hash(tuple(hash_list))
-
-        channel_pair = TaborChannelPair(self.instrument, identifier='asd', channels=(1, 2))
-
-        # empty
-        segments = np.asarray([-5, -6, -7, -8, -9])
-        segment_lengths = 192 + np.asarray([32, 16, 64, 32, 16])
-
-        hash_before = hash_based_on_dir(channel_pair)
-
-        w2s, ta, ti = channel_pair._find_place_for_segments_in_memory(segments, segment_lengths)
-        self.assertEqual(w2s.tolist(), [-1, -1, -1, -1, -1])
-        self.assertEqual(ta.tolist(), [True, True, True, True, True])
-        self.assertEqual(ti.tolist(), [-1, -1, -1, -1, -1])
-        self.assertEqual(hash_before, hash_based_on_dir(channel_pair))
-
-        # all new segments
-        channel_pair._segment_capacity = 192 + np.asarray([0, 16, 32, 16, 0], dtype=np.uint32)
-        channel_pair._segment_hashes = np.asarray([1, 2, 3, 4, 5], dtype=np.int64)
-        channel_pair._segment_references = np.asarray([1, 1, 1, 2, 1], dtype=np.int32)
-        hash_before = hash_based_on_dir(channel_pair)
-
-        w2s, ta, ti = channel_pair._find_place_for_segments_in_memory(segments, segment_lengths)
-        self.assertEqual(w2s.tolist(), [-1, -1, -1, -1, -1])
-        self.assertEqual(ta.tolist(), [True, True, True, True, True])
-        self.assertEqual(ti.tolist(), [-1, -1, -1, -1, -1])
-        self.assertEqual(hash_before, hash_based_on_dir(channel_pair))
-
-        # some known segments
-        channel_pair._segment_capacity = 192 + np.asarray([0, 16, 32, 64, 0, 16], dtype=np.uint32)
-        channel_pair._segment_hashes = np.asarray([1, 2, 3, -7, 5, -9], dtype=np.int64)
-        channel_pair._segment_references = np.asarray([1, 1, 1, 2, 1, 3], dtype=np.int32)
-        hash_before = hash_based_on_dir(channel_pair)
-
-        w2s, ta, ti = channel_pair._find_place_for_segments_in_memory(segments, segment_lengths)
-        self.assertEqual(w2s.tolist(), [-1, -1, 3, -1, 5])
-        self.assertEqual(ta.tolist(), [True, True, False, True, False])
-        self.assertEqual(ti.tolist(), [-1, -1, -1, -1, -1])
-        self.assertEqual(hash_before, hash_based_on_dir(channel_pair))
-
-        # insert some segments with same length
-        channel_pair._segment_capacity = 192 + np.asarray([0, 16, 32, 64, 0, 16], dtype=np.uint32)
-        channel_pair._segment_hashes = np.asarray([1, 2, 3, 4, 5, 6], dtype=np.int64)
-        channel_pair._segment_references = np.asarray([1, 0, 1, 0, 1, 3], dtype=np.int32)
-        hash_before = hash_based_on_dir(channel_pair)
-
-        w2s, ta, ti = channel_pair._find_place_for_segments_in_memory(segments, segment_lengths)
-        self.assertEqual(w2s.tolist(), [-1, -1, -1, -1, -1])
-        self.assertEqual(ta.tolist(), [True, False, False, True, True])
-        self.assertEqual(ti.tolist(), [-1, 1, 3, -1, -1])
-        self.assertEqual(hash_before, hash_based_on_dir(channel_pair))
-
-        # insert some segments with smaller length
-        channel_pair._segment_capacity = 192 + np.asarray([0, 80, 32, 64, 96, 16], dtype=np.uint32)
-        channel_pair._segment_hashes = np.asarray([1, 2, 3, 4, 5, 6], dtype=np.int64)
-        channel_pair._segment_references = np.asarray([1, 0, 1, 1, 0, 3], dtype=np.int32)
-        hash_before = hash_based_on_dir(channel_pair)
-
-        w2s, ta, ti = channel_pair._find_place_for_segments_in_memory(segments, segment_lengths)
-        self.assertEqual(w2s.tolist(), [-1, -1, -1, -1, -1])
-        self.assertEqual(ta.tolist(), [True, True, False, False, True])
-        self.assertEqual(ti.tolist(), [-1, -1, 4, 1, -1])
-        self.assertEqual(hash_before, hash_based_on_dir(channel_pair))
-
-        # mix everything
-        segments = np.asarray([-5, -6, -7, -8, -9, -10, -11])
-        segment_lengths = 192 + np.asarray([32, 16, 64, 32, 16, 0, 0])
-
-        channel_pair._segment_capacity = 192 + np.asarray([0, 80, 32, 64, 32, 16], dtype=np.uint32)
-        channel_pair._segment_hashes = np.asarray([1, 2, 3, 4, -8, 6], dtype=np.int64)
-        channel_pair._segment_references = np.asarray([1, 0, 1, 0, 1, 0], dtype=np.int32)
-        hash_before = hash_based_on_dir(channel_pair)
-
-        w2s, ta, ti = channel_pair._find_place_for_segments_in_memory(segments, segment_lengths)
-        self.assertEqual(w2s.tolist(), [-1,    -1,   -1,    4,     -1,     -1, -1])
-        self.assertEqual(ta.tolist(),  [False, True, False, False, True, True, True])
-        self.assertEqual(ti.tolist(),  [1,     -1,   3,     -1,    -1,   -1,   -1])
-        self.assertEqual(hash_before, hash_based_on_dir(channel_pair))
-
     def test_upload_segment(self):
         channel_pair = TaborChannelPair(self.instrument, identifier='asd', channels=(1, 2))
 
diff --git a/tests/hardware/tabor_simulator_based_tests.py b/tests/hardware/tabor_simulator_based_tests.py
index 97a715424..ace3b2fbc 100644
--- a/tests/hardware/tabor_simulator_based_tests.py
+++ b/tests/hardware/tabor_simulator_based_tests.py
@@ -7,12 +7,16 @@
 try:
     import pyvisa.resources
     import tabor_control
-except ImportError as err:
-    raise unittest.SkipTest("pyvisa and/or tabor_control not present") from err
+except ImportError:
+    tabor_control = None
+    pyvisa = None
 
 import numpy as np
 
-from qupulse.hardware.awgs.tabor import TaborAWGRepresentation, TaborChannelPair
+try:
+    from qupulse.hardware.awgs.tabor import TaborAWGRepresentation, TaborChannelPair
+except ImportError:
+    pass
 from qupulse._program.tabor import TaborSegment, PlottableProgram, TaborException, TableDescription, TableEntry
 from typing import List, Tuple, Optional, Any
 
@@ -48,7 +52,7 @@ def kill_running_simulators(self):
     def simulator_full_path(self):
         return os.path.join(self.simulator_path, self.simulator_executable)
 
-    def start_simulator(self, try_connecting_to_existing_simulator=True, max_wait_time=30) -> pyvisa.resources.MessageBasedResource:
+    def start_simulator(self, try_connecting_to_existing_simulator=True, max_wait_time=30) -> 'pyvisa.resources.MessageBasedResource':
         try:
             pyvisa.ResourceManager()
         except ValueError:
@@ -95,7 +99,7 @@ def __del__(self):
             self.simulator_process.kill()
 
 
-@unittest.skipIf(platform.system() != 'Windows', "Simulator currently only available on Windows :(")
+@unittest.skipIf(tabor_control is None or platform.system() != 'Windows', "Simulator currently only available on Windows :(")
 class TaborSimulatorBasedTest(unittest.TestCase):
     simulator_manager = None
 
diff --git a/tests/hardware/tabor_tests.py b/tests/hardware/tabor_tests.py
index 6226b2c63..e9bf6e5bf 100644
--- a/tests/hardware/tabor_tests.py
+++ b/tests/hardware/tabor_tests.py
@@ -9,7 +9,7 @@
 
 from qupulse.hardware.awgs.tabor import TaborException, TaborProgram, \
     TaborSegment, TaborSequencing, with_configuration_guard, PlottableProgram
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 from qupulse.hardware.util import voltage_to_uint16
 
 from tests.pulses.sequencing_dummies import DummyWaveform
diff --git a/tests/hardware/tektronix_tests.py b/tests/hardware/tektronix_tests.py
index 773876e2e..aed67d79e 100644
--- a/tests/hardware/tektronix_tests.py
+++ b/tests/hardware/tektronix_tests.py
@@ -13,7 +13,7 @@
 
 from qupulse.hardware.awgs.tektronix import TektronixAWG, TektronixProgram, parse_program, _make_binary_waveform,\
     voltage_to_uint16, WaveformEntry, WaveformStorage
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 from qupulse.utils.types import TimeType
 from tests.pulses.sequencing_dummies import DummyWaveform
 from qupulse._program.waveforms import MultiChannelWaveform
diff --git a/tests/hardware/zihdawg_tests.py b/tests/hardware/zihdawg_tests.py
deleted file mode 100644
index 9fe675eab..000000000
--- a/tests/hardware/zihdawg_tests.py
+++ /dev/null
@@ -1,217 +0,0 @@
-import unittest
-from unittest import mock
-from collections import OrderedDict
-
-import numpy as np
-
-try:
-    import pytest
-except ImportError:
-    pytest = None
-
-if pytest:
-    zhinst = pytest.importorskip("zhinst")
-
-    try:
-        import zhinst.core as zhinst_core
-    except ImportError:
-        import zhinst.ziPython as zhinst_core
-else:
-    try:
-        try:
-            import zhinst.core as zhinst_core
-        except ImportError:
-            import zhinst.ziPython as zhinst_core
-    except ImportError as err:
-        raise unittest.SkipTest("zhinst not present") from err
-
-from qupulse.utils.types import TimeType
-from qupulse._program._loop import Loop
-from tests.pulses.sequencing_dummies import DummyWaveform
-from qupulse.hardware.awgs.zihdawg import HDAWGChannelGroup, HDAWGRepresentation, HDAWGValueError, UserRegister,\
-    ELFManager, HDAWGChannelGrouping, SingleDeviceChannelGroup
-
-
-class HDAWGRepresentationTests(unittest.TestCase):
-    def test_init(self):
-        """We do not test anything lab one related"""
-        device_serial = 'dev6ä6ä6'
-        device_interface = 'telepathy'
-        data_server_addr = 'asd'
-        data_server_port = 42
-        api_level_number = 23
-        channel_grouping = HDAWGChannelGrouping.CHAN_GROUP_1x8
-
-        with \
-                mock.patch('zhinst.utils.api_server_version_check') as mock_version_check,\
-                mock.patch.object(zhinst_core, 'ziDAQServer') as mock_daq_server, \
-                mock.patch('qupulse.hardware.awgs.zihdawg.HDAWGRepresentation._initialize') as mock_init, \
-                mock.patch('qupulse.hardware.awgs.zihdawg.HDAWGRepresentation.channel_grouping', new_callable=mock.PropertyMock) as mock_grouping, \
-                mock.patch('qupulse.hardware.awgs.zihdawg.SingleDeviceChannelGroup') as mock_channel_pair,\
-                mock.patch('zhinst.utils.disable_everything') as mock_reset,\
-                mock.patch('pathlib.Path') as mock_path:
-
-            representation = HDAWGRepresentation(device_serial,
-                                                 device_interface,
-                                                 data_server_addr, data_server_port, api_level_number,
-                                                 False, 1.3, grouping=channel_grouping)
-
-            mock_daq_server.return_value.awgModule.return_value.getString.assert_called_once_with('directory')
-            module_dir = mock_daq_server.return_value.awgModule.return_value.getString.return_value
-            mock_path.assert_called_once_with(module_dir, 'awg', 'waves')
-
-            self.assertIs(representation.api_session, mock_daq_server.return_value)
-            mock_daq_server.assert_called_once_with(data_server_addr, data_server_port, api_level_number)
-
-            mock_version_check.assert_called_once_with(representation.api_session)
-            representation.api_session.connectDevice.assert_called_once_with(device_serial, device_interface)
-            self.assertEqual(device_serial, representation.serial)
-
-            mock_grouping.assert_called_once_with(channel_grouping)
-
-            mock_reset.assert_not_called()
-            mock_init.assert_called_once_with()
-
-            group_calls = [mock.call(0, 2, identifier=str(device_serial) + '_AB', timeout=1.3),
-                           mock.call(1, 2, identifier=str(device_serial) + '_CD', timeout=1.3),
-                           mock.call(2, 2, identifier=str(device_serial) + '_EF', timeout=1.3),
-                           mock.call(3, 2, identifier=str(device_serial) + '_GH', timeout=1.3),
-                           mock.call(0, 4, identifier=str(device_serial) + '_ABCD', timeout=1.3),
-                           mock.call(1, 4, identifier=str(device_serial) + '_EFGH', timeout=1.3),
-                           mock.call(0, 8, identifier=str(device_serial) + '_ABCDEFGH', timeout=1.3)]
-            for c1, c2 in zip(group_calls, mock_channel_pair.call_args_list):
-                self.assertEqual(c1, c2)
-
-            self.assertIs(representation.channel_pair_AB, mock_channel_pair.return_value)
-            self.assertIs(representation.channel_pair_CD, mock_channel_pair.return_value)
-            self.assertIs(representation.channel_pair_EF, mock_channel_pair.return_value)
-            self.assertIs(representation.channel_pair_GH, mock_channel_pair.return_value)
-
-            mock_version_check.reset_mock()
-            mock_daq_server.reset_mock()
-            mock_init.reset_mock()
-            mock_channel_pair.reset_mock()
-            mock_reset.reset_mock()
-
-            representation = HDAWGRepresentation(device_serial,
-                                                 device_interface,
-                                                 data_server_addr, data_server_port, api_level_number, True)
-
-            self.assertIs(representation.api_session, mock_daq_server.return_value)
-            mock_daq_server.assert_called_once_with(data_server_addr, data_server_port, api_level_number)
-
-            mock_version_check.assert_called_once_with(representation.api_session)
-            representation.api_session.connectDevice.assert_called_once_with(device_serial, device_interface)
-            self.assertEqual(device_serial, representation.serial)
-
-            mock_reset.assert_called_once_with(representation.api_session, representation.serial)
-            mock_init.assert_called_once_with()
-
-            group_calls = [mock.call(0, 2, identifier=str(device_serial) + '_AB', timeout=20),
-                           mock.call(1, 2, identifier=str(device_serial) + '_CD', timeout=20),
-                           mock.call(2, 2, identifier=str(device_serial) + '_EF', timeout=20),
-                           mock.call(3, 2, identifier=str(device_serial) + '_GH', timeout=20),
-                           mock.call(0, 4, identifier=str(device_serial) + '_ABCD', timeout=20),
-                           mock.call(1, 4, identifier=str(device_serial) + '_EFGH', timeout=20),
-                           mock.call(0, 8, identifier=str(device_serial) + '_ABCDEFGH', timeout=20)]
-            self.assertEqual(group_calls, mock_channel_pair.call_args_list)
-
-            self.assertIs(representation.channel_pair_AB, mock_channel_pair.return_value)
-            self.assertIs(representation.channel_pair_CD, mock_channel_pair.return_value)
-            self.assertIs(representation.channel_pair_EF, mock_channel_pair.return_value)
-            self.assertIs(representation.channel_pair_GH, mock_channel_pair.return_value)
-
-
-class HDAWGChannelGroupTests(unittest.TestCase):
-    def test_init(self):
-        with mock.patch('weakref.proxy') as proxy_mock:
-            mock_device = mock.Mock()
-
-            channels = (3, 4)
-            awg_group_idx = 1
-
-            channel_pair = SingleDeviceChannelGroup(awg_group_idx, 2, 'foo', 3.4)
-
-            self.assertEqual(channel_pair.timeout, 3.4)
-            self.assertEqual(channel_pair._channels(), channels)
-            self.assertEqual(channel_pair.awg_group_index, awg_group_idx)
-            self.assertEqual(channel_pair.num_channels, 2)
-            self.assertEqual(channel_pair.num_markers, 4)
-
-            self.assertFalse(channel_pair.is_connected())
-
-            proxy_mock.return_value.channel_grouping = HDAWGChannelGrouping.CHAN_GROUP_4x2
-
-            channel_pair.connect_group(mock_device)
-            self.assertTrue(channel_pair.is_connected())
-            proxy_mock.assert_called_once_with(mock_device)
-            self.assertIs(channel_pair.master_device, proxy_mock.return_value)
-            self.assertIs(channel_pair.awg_module, channel_pair.master_device.api_session.awgModule.return_value)
-
-    def test_set_volatile_parameters(self):
-        mock_device = mock.Mock()
-
-        parameters = {'a': 9}
-        requested_changes = OrderedDict([(UserRegister.from_seqc(4), 2), (UserRegister.from_seqc(3), 6)])
-
-        expected_user_reg_calls = [mock.call(*args) for args in requested_changes.items()]
-
-        channel_pair = SingleDeviceChannelGroup(1, 2, 'foo', 3.4)
-
-        channel_pair._current_program = 'active_program'
-        with mock.patch.object(channel_pair._program_manager, 'get_register_values_to_update_volatile_parameters',
-                               return_value=requested_changes) as get_reg_val:
-            with mock.patch.object(channel_pair, 'user_register') as user_register:
-                channel_pair.set_volatile_parameters('other_program', parameters)
-
-                user_register.assert_not_called()
-                get_reg_val.assert_called_once_with('other_program', parameters)
-
-        with mock.patch.object(channel_pair._program_manager, 'get_register_values_to_update_volatile_parameters',
-                               return_value=requested_changes) as get_reg_val:
-            with mock.patch.object(channel_pair, 'user_register') as user_register:
-                channel_pair.set_volatile_parameters('active_program', parameters)
-
-                self.assertEqual(expected_user_reg_calls, user_register.call_args_list)
-                get_reg_val.assert_called_once_with('active_program', parameters)
-
-    def test_upload(self):
-        mock_loop = mock.MagicMock(wraps=Loop(repetition_count=2,
-                                              waveform=DummyWaveform(duration=192,
-                                                                     sample_output=np.arange(192) / 192)))
-
-        voltage_trafos = (lambda x: x, lambda x: x)
-
-        with mock.patch('weakref.proxy'),\
-             mock.patch('qupulse.hardware.awgs.zihdawg.make_compatible') as mock_make_compatible:
-            channel_pair = SingleDeviceChannelGroup(1, 2, 'foo', 3.4)
-
-            with self.assertRaisesRegex(HDAWGValueError, 'Channel ID'):
-                channel_pair.upload('bar', mock_loop, ('A'), (None, 'A', None, None), voltage_trafos)
-            with self.assertRaisesRegex(HDAWGValueError, 'Markers'):
-                channel_pair.upload('bar', mock_loop, ('A', None), (None, 'A', None), voltage_trafos)
-            with self.assertRaisesRegex(HDAWGValueError, 'transformations'):
-                channel_pair.upload('bar', mock_loop, ('A', None), (None, 'A', None, None), voltage_trafos[:1])
-
-            # TODO: draw the rest of the owl
-
-
-@mock.patch('qupulse.hardware.awgs.zihdawg.ELFManager.AWGModule.compiler_upload', new_callable=mock.PropertyMock)
-class ELFManagerTests(unittest.TestCase):
-    def test_init(self, compiler_upload):
-        manager = ELFManager(None)
-        compiler_upload.assert_called_once_with(True)
-        self.assertIsNone(manager._compile_job)
-        self.assertIsNone(manager._upload_job)
-
-    @unittest.skip("Write test after more hardware tests")
-    def test_upload(self, compiler_upload):
-        raise NotImplementedError()
-
-    @unittest.skip("Write test after more hardware tests")
-    def test_update_compile_job_status(self, compiler_upload):
-        raise NotImplementedError()
-
-    @unittest.skip("Write test after more hardware tests")
-    def test_compile(self, compiler_upload):
-        raise NotImplementedError()
diff --git a/tests/program/linspace_tests.py b/tests/program/linspace_tests.py
new file mode 100644
index 000000000..03a5b2971
--- /dev/null
+++ b/tests/program/linspace_tests.py
@@ -0,0 +1,289 @@
+import copy
+import unittest
+from unittest import TestCase
+
+from qupulse.pulses import *
+from qupulse.program.linspace import *
+from qupulse.program.transformation import *
+
+class SingleRampTest(TestCase):
+    def setUp(self):
+        hold = ConstantPT(10 ** 6, {'a': '-1. + idx * 0.01'})
+        self.pulse_template = hold.with_iteration('idx', 200)
+
+        self.program = LinSpaceIter(
+            length=200,
+            body=(LinSpaceHold(
+                bases=(-1.,),
+                factors=((0.01,),),
+                duration_base=TimeType(10**6),
+                duration_factors=None
+            ),)
+        )
+
+        key = DepKey.from_voltages((0.01,), DEFAULT_INCREMENT_RESOLUTION)
+
+        self.commands = [
+            Set(0, -1.0, key),
+            Wait(TimeType(10 ** 6)),
+            LoopLabel(0, 199),
+            Increment(0, 0.01, key),
+            Wait(TimeType(10 ** 6)),
+            LoopJmp(0)
+        ]
+
+    def test_program(self):
+        program_builder = LinSpaceBuilder(('a',))
+        program = self.pulse_template.create_program(program_builder=program_builder)
+        self.assertEqual([self.program], program)
+
+    def test_commands(self):
+        commands = to_increment_commands([self.program])
+        self.assertEqual(self.commands, commands)
+
+
+class PlainCSDTest(TestCase):
+    def setUp(self):
+        hold = ConstantPT(10**6, {'a': '-1. + idx_a * 0.01', 'b': '-.5 + idx_b * 0.02'})
+        scan_a = hold.with_iteration('idx_a', 200)
+        self.pulse_template = scan_a.with_iteration('idx_b', 100)
+
+        self.program = LinSpaceIter(length=100, body=(LinSpaceIter(
+            length=200,
+            body=(LinSpaceHold(
+                bases=(-1., -0.5),
+                factors=((0.0, 0.01),
+                         (0.02, 0.0)),
+                duration_base=TimeType(10**6),
+                duration_factors=None
+            ),)
+        ),))
+
+        key_0 = DepKey.from_voltages((0, 0.01,), DEFAULT_INCREMENT_RESOLUTION)
+        key_1 = DepKey.from_voltages((0.02,), DEFAULT_INCREMENT_RESOLUTION)
+
+        self.commands = [
+            Set(0, -1.0, key_0),
+            Set(1, -0.5, key_1),
+            Wait(TimeType(10 ** 6)),
+
+            LoopLabel(0, 199),
+            Increment(0, 0.01, key_0),
+            Wait(TimeType(10 ** 6)),
+            LoopJmp(0),
+
+            LoopLabel(1, 99),
+
+            Increment(0, -2.0, key_0),
+            Increment(1, 0.02, key_1),
+            Wait(TimeType(10 ** 6)),
+
+            LoopLabel(2, 199),
+            Increment(0, 0.01, key_0),
+            Wait(TimeType(10 ** 6)),
+            LoopJmp(2),
+
+            LoopJmp(1),
+        ]
+
+    def test_program(self):
+        program_builder = LinSpaceBuilder(('a', 'b'))
+        program = self.pulse_template.create_program(program_builder=program_builder)
+        self.assertEqual([self.program], program)
+
+    def test_increment_commands(self):
+        commands = to_increment_commands([self.program])
+        self.assertEqual(self.commands, commands)
+
+
+class TiltedCSDTest(TestCase):
+    def setUp(self):
+        hold = ConstantPT(10**6, {'a': '-1. + idx_a * 0.01 + idx_b * 1e-3', 'b': '-.5 + idx_b * 0.02 - 3e-3 * idx_a'})
+        scan_a = hold.with_iteration('idx_a', 200)
+        self.pulse_template = scan_a.with_iteration('idx_b', 100)
+        self.repeated_pt = self.pulse_template.with_repetition(42)
+
+        self.program = LinSpaceIter(length=100, body=(LinSpaceIter(
+            length=200,
+            body=(LinSpaceHold(
+                bases=(-1., -0.5),
+                factors=((1e-3, 0.01),
+                         (0.02, -3e-3)),
+                duration_base=TimeType(10**6),
+                duration_factors=None
+            ),)
+        ),))
+        self.repeated_program = LinSpaceRepeat(body=(self.program,), count=42)
+
+        key_0 = DepKey.from_voltages((1e-3, 0.01,), DEFAULT_INCREMENT_RESOLUTION)
+        key_1 = DepKey.from_voltages((0.02, -3e-3), DEFAULT_INCREMENT_RESOLUTION)
+
+        self.commands = [
+            Set(0, -1.0, key_0),
+            Set(1, -0.5, key_1),
+            Wait(TimeType(10 ** 6)),
+
+            LoopLabel(0, 199),
+            Increment(0, 0.01, key_0),
+            Increment(1, -3e-3, key_1),
+            Wait(TimeType(10 ** 6)),
+            LoopJmp(0),
+
+            LoopLabel(1, 99),
+
+            Increment(0, 1e-3 + -200 * 1e-2, key_0),
+            Increment(1, 0.02 + -200 * -3e-3, key_1),
+            Wait(TimeType(10 ** 6)),
+
+            LoopLabel(2, 199),
+            Increment(0, 0.01, key_0),
+            Increment(1, -3e-3, key_1),
+            Wait(TimeType(10 ** 6)),
+            LoopJmp(2),
+
+            LoopJmp(1),
+        ]
+        inner_commands = copy.deepcopy(self.commands)
+        for cmd in inner_commands:
+            if hasattr(cmd, 'idx'):
+                cmd.idx += 1
+        self.repeated_commands = [LoopLabel(0, 42)] + inner_commands + [LoopJmp(0)]
+
+    def test_program(self):
+        program_builder = LinSpaceBuilder(('a', 'b'))
+        program = self.pulse_template.create_program(program_builder=program_builder)
+        self.assertEqual([self.program], program)
+
+    def test_repeated_program(self):
+        program_builder = LinSpaceBuilder(('a', 'b'))
+        program = self.repeated_pt.create_program(program_builder=program_builder)
+        self.assertEqual([self.repeated_program], program)
+
+    def test_increment_commands(self):
+        commands = to_increment_commands([self.program])
+        self.assertEqual(self.commands, commands)
+
+    def test_repeated_increment_commands(self):
+        commands = to_increment_commands([self.repeated_program])
+        self.assertEqual(self.repeated_commands, commands)
+
+
+class SingletLoadProcessing(TestCase):
+    def setUp(self):
+        wait = ConstantPT(10 ** 6, {'a': '-1. + idx_a * 0.01', 'b': '-.5 + idx_b * 0.02'})
+        load_random = ConstantPT(10 ** 5, {'a': -.4, 'b': -.3})
+        meas = ConstantPT(10 ** 5, {'a': 0.05, 'b': 0.06})
+
+        singlet_scan = (load_random @ wait @ meas).with_iteration('idx_a', 200).with_iteration('idx_b', 100)
+        self.pulse_template = singlet_scan
+
+        self.program = LinSpaceIter(length=100, body=(LinSpaceIter(
+            length=200,
+            body=(
+                LinSpaceHold(bases=(-0.4, -0.3), factors=(None, None), duration_base=TimeType(10 ** 5),
+                             duration_factors=None),
+                LinSpaceHold(bases=(-1., -0.5),
+                             factors=((0.0, 0.01),
+                                      (0.02, 0.0)),
+                             duration_base=TimeType(10 ** 6),
+                             duration_factors=None),
+                LinSpaceHold(bases=(0.05, 0.06), factors=(None, None), duration_base=TimeType(10 ** 5),
+                             duration_factors=None),
+            )
+        ),))
+
+        key_0 = DepKey.from_voltages((0, 0.01,), DEFAULT_INCREMENT_RESOLUTION)
+        key_1 = DepKey.from_voltages((0.02,), DEFAULT_INCREMENT_RESOLUTION)
+
+        self.commands = [
+            Set(0, -0.4),
+            Set(1, -0.3),
+            Wait(TimeType(10 ** 5)),
+            Set(0, -1.0, key_0),
+            Set(1, -0.5, key_1),
+            Wait(TimeType(10 ** 6)),
+            Set(0, 0.05),
+            Set(1, 0.06),
+            Wait(TimeType(10 ** 5)),
+
+            LoopLabel(0, 199),
+            Set(0, -0.4),
+            Set(1, -0.3),
+            Wait(TimeType(10 ** 5)),
+            Increment(0, 0.01, key_0),
+            Increment(1, 0.00, key_1),
+            Wait(TimeType(10 ** 6)),
+            Set(0, 0.05),
+            Set(1, 0.06),
+            Wait(TimeType(10 ** 5)),
+            LoopJmp(0),
+
+            LoopLabel(1, 99),
+
+            Set(0, -0.4),
+            Set(1, -0.3),
+            Wait(TimeType(10 ** 5)),
+            Increment(0, -2.0, key_0),
+            Increment(1, 0.02, key_1),
+            Wait(TimeType(10 ** 6)),
+            Set(0, 0.05),
+            Set(1, 0.06),
+            Wait(TimeType(10 ** 5)),
+
+            LoopLabel(2, 199),
+
+            Set(0, -0.4),
+            Set(1, -0.3),
+            Wait(TimeType(10 ** 5)),
+            Increment(0, 0.01, key_0),
+            Increment(1, 0.00, key_1),
+            Wait(TimeType(10 ** 6)),
+            Set(0, 0.05),
+            Set(1, 0.06),
+            Wait(TimeType(10 ** 5)),
+
+            LoopJmp(2),
+
+            LoopJmp(1),
+        ]
+
+    def test_singlet_scan_program(self):
+        program_builder = LinSpaceBuilder(('a', 'b'))
+        program = self.pulse_template.create_program(program_builder=program_builder)
+        self.assertEqual([self.program], program)
+
+    def test_singlet_scan_commands(self):
+        commands = to_increment_commands([self.program])
+        self.assertEqual(self.commands, commands)
+
+
+class TransformedRampTest(TestCase):
+    def setUp(self):
+        hold = ConstantPT(10 ** 6, {'a': '-1. + idx * 0.01'})
+        self.pulse_template = hold.with_iteration('idx', 200)
+        self.transformation = ScalingTransformation({'a': 2.0})
+
+        self.program = LinSpaceIter(
+            length=200,
+            body=(LinSpaceHold(
+                bases=(-2.,),
+                factors=((0.02,),),
+                duration_base=TimeType(10 ** 6),
+                duration_factors=None
+            ),)
+        )
+
+    def test_global_trafo_program(self):
+        program_builder = LinSpaceBuilder(('a',))
+        program = self.pulse_template.create_program(program_builder=program_builder,
+                                                     global_transformation=self.transformation)
+        self.assertEqual([self.program], program)
+
+    def test_local_trafo_program(self):
+        program_builder = LinSpaceBuilder(('a',))
+        with self.assertRaises(NotImplementedError):
+            # not implemented yet. This test should work as soon as its implemented
+            program = self.pulse_template.create_program(program_builder=program_builder,
+                                                         global_transformation=self.transformation,
+                                                         to_single_waveform={self.pulse_template})
+            self.assertEqual([self.program], program)
diff --git a/tests/pulses/arithmetic_pulse_template_tests.py b/tests/pulses/arithmetic_pulse_template_tests.py
index b4d821684..66ff074c8 100644
--- a/tests/pulses/arithmetic_pulse_template_tests.py
+++ b/tests/pulses/arithmetic_pulse_template_tests.py
@@ -433,7 +433,7 @@ def test_internal_create_program(self):
         measurement_mapping = dict(m1='m2')
         global_transformation = OffsetTransformation({'unrelated': 1.})
         to_single_waveform = {'something_else'}
-        parent_loop = mock.Mock()
+        program_builder = mock.Mock()
 
         expected_transformation = mock.Mock(spec=IdentityTransformation())
 
@@ -448,7 +448,7 @@ def test_internal_create_program(self):
                     channel_mapping=channel_mapping,
                     global_transformation=global_transformation,
                     to_single_waveform=to_single_waveform,
-                    parent_loop=parent_loop
+                    program_builder=program_builder
                 )
                 get_transformation.assert_called_once_with(parameters=scope, channel_mapping=channel_mapping)
 
@@ -459,7 +459,7 @@ def test_internal_create_program(self):
                 channel_mapping=channel_mapping,
                 global_transformation=expected_transformation,
                 to_single_waveform=to_single_waveform,
-                parent_loop=parent_loop
+                program_builder=program_builder
             )
 
             with self.assertRaisesRegex(NotImplementedError, 'volatile'):
@@ -469,7 +469,7 @@ def test_internal_create_program(self):
                     channel_mapping=channel_mapping,
                     global_transformation=global_transformation,
                     to_single_waveform=to_single_waveform,
-                    parent_loop=parent_loop
+                    program_builder=program_builder
                 )
 
     def test_integral(self):
diff --git a/tests/pulses/bug_tests.py b/tests/pulses/bug_tests.py
index 6be11fcf2..44fd49677 100644
--- a/tests/pulses/bug_tests.py
+++ b/tests/pulses/bug_tests.py
@@ -13,7 +13,7 @@
 
 from qupulse.plotting import plot
 
-from qupulse._program._loop import to_waveform
+from qupulse.program.loop import to_waveform
 from qupulse.utils import isclose
 
 class BugTests(unittest.TestCase):
@@ -77,7 +77,7 @@ def test_issue_584_uninitialized_table_sample(self):
         """issue 584"""
         d = 598.3333333333334 - 480
         tpt = TablePulseTemplate(entries={'P': [(0, 1.0, 'hold'), (d, 1.0, 'hold')]})
-        with mock.patch('qupulse._program.waveforms.PULSE_TO_WAVEFORM_ERROR', 1e-6):
+        with mock.patch('qupulse.program.waveforms.PULSE_TO_WAVEFORM_ERROR', 1e-6):
             wf = to_waveform(tpt.create_program())
             self.assertTrue(isclose(d, wf.duration, abs_tol=1e-6))
 
diff --git a/tests/pulses/constant_pulse_template_tests.py b/tests/pulses/constant_pulse_template_tests.py
index f0e374869..197aa1017 100644
--- a/tests/pulses/constant_pulse_template_tests.py
+++ b/tests/pulses/constant_pulse_template_tests.py
@@ -1,14 +1,14 @@
 import unittest
 
 import qupulse.plotting
-import qupulse._program.waveforms
+import qupulse.program.waveforms
 import qupulse.utils.sympy
 from qupulse.pulses import TablePT, FunctionPT, AtomicMultiChannelPT, MappingPT
 from qupulse.pulses.multi_channel_pulse_template import AtomicMultiChannelPulseTemplate
 from qupulse.plotting import plot
 from qupulse.pulses.sequence_pulse_template import SequencePulseTemplate
-from qupulse._program._loop import make_compatible
-from qupulse._program.waveforms import ConstantWaveform
+from qupulse.program.loop import make_compatible
+from qupulse.program.waveforms import ConstantWaveform
 
 from qupulse.serialization import DictBackend, PulseStorage
 from qupulse.pulses.constant_pulse_template import ConstantPulseTemplate, ExpressionScalar, TimeType
@@ -44,10 +44,10 @@ def test_zero_duration(self):
         self.assertEqual(pulse.duration, 12)
 
     def test_regression_duration_conversion(self):
-        old_value = qupulse._program.waveforms.PULSE_TO_WAVEFORM_ERROR
+        old_value = qupulse.program.waveforms.PULSE_TO_WAVEFORM_ERROR
 
         try:
-            qupulse._program.waveforms.PULSE_TO_WAVEFORM_ERROR = 1e-6
+            qupulse.program.waveforms.PULSE_TO_WAVEFORM_ERROR = 1e-6
             for duration_in_samples in [64, 936320, 24615392]:
                 p = ConstantPulseTemplate(duration_in_samples / 2.4, {'a': 0})
                 number_of_samples = p.create_program().duration * 2.4
@@ -57,19 +57,19 @@ def test_regression_duration_conversion(self):
                 p2 = ConstantPulseTemplate((duration_in_samples + 1) / 2.4, {'a': 0})
                 self.assertNotEqual(p.create_program().duration, p2.create_program().duration)
         finally:
-            qupulse._program.waveforms.PULSE_TO_WAVEFORM_ERROR = old_value
+            qupulse.program.waveforms.PULSE_TO_WAVEFORM_ERROR = old_value
 
     def test_regression_duration_conversion_functionpt(self):
-        old_value = qupulse._program.waveforms.PULSE_TO_WAVEFORM_ERROR
+        old_value = qupulse.program.waveforms.PULSE_TO_WAVEFORM_ERROR
 
         try:
-            qupulse._program.waveforms.PULSE_TO_WAVEFORM_ERROR = 1e-6
+            qupulse.program.waveforms.PULSE_TO_WAVEFORM_ERROR = 1e-6
             for duration_in_samples in [64, 2000, 936320]:
                 p = FunctionPT('1', duration_expression=duration_in_samples / 2.4, channel='a')
                 number_of_samples = p.create_program().duration * 2.4
                 self.assertEqual(number_of_samples.denominator, 1)
         finally:
-            qupulse._program.waveforms.PULSE_TO_WAVEFORM_ERROR = old_value
+            qupulse.program.waveforms.PULSE_TO_WAVEFORM_ERROR = old_value
 
     def test_regression_template_combination(self):
         old_value = qupulse.utils.sympy.SYMPY_DURATION_ERROR_MARGIN
diff --git a/tests/pulses/loop_pulse_template_tests.py b/tests/pulses/loop_pulse_template_tests.py
index 0df6a2a04..c6a658bf8 100644
--- a/tests/pulses/loop_pulse_template_tests.py
+++ b/tests/pulses/loop_pulse_template_tests.py
@@ -10,7 +10,7 @@
 from qupulse.pulses.parameters import InvalidParameterNameException, ParameterConstraintViolation,\
     ParameterNotProvidedException, ParameterConstraint
 
-from qupulse._program._loop import Loop
+from qupulse.program.loop import LoopBuilder, Loop
 
 from tests.pulses.sequencing_dummies import DummyPulseTemplate, MeasurementWindowTestCase, DummyWaveform
 from tests.serialization_dummies import DummySerializer
@@ -209,12 +209,13 @@ def test_create_program_constraint_on_loop_var_exception(self):
         # loop index not accessible in current build_sequence -> Exception
         children = [Loop(waveform=DummyWaveform(duration=2.0))]
         program = Loop(children=children)
+        program_builder = LoopBuilder._testing_dummy([program])
 
         with self.assertRaises(ParameterNotProvidedException):
             flt._internal_create_program(scope=scope,
                                          measurement_mapping=dict(),
                                          channel_mapping=dict(),
-                                         parent_loop=program,
+                                         program_builder=program_builder,
                                          to_single_waveform=set(),
                                          global_transformation=None)
         self.assertEqual(children, list(program.children))
@@ -234,11 +235,13 @@ def test_create_program_invalid_params(self) -> None:
 
         children = [Loop(waveform=DummyWaveform(duration=2.0))]
         program = Loop(children=children)
+        program_builder = LoopBuilder._testing_dummy([program])
+
         with self.assertRaises(ParameterConstraintViolation):
             flt._internal_create_program(scope=invalid_scope,
                                          measurement_mapping=measurement_mapping,
                                          channel_mapping=channel_mapping,
-                                         parent_loop=program,
+                                         program_builder=program_builder,
                                          to_single_waveform=set(),
                                          global_transformation=None)
 
@@ -259,11 +262,13 @@ def test_create_program_invalid_measurement_mapping(self) -> None:
 
         children = [Loop(waveform=DummyWaveform(duration=2.0))]
         program = Loop(children=children)
+        program_builder = LoopBuilder._testing_dummy([program])
+
         with self.assertRaises(KeyError):
             flt._internal_create_program(scope=invalid_scope,
                                          measurement_mapping=measurement_mapping,
                                          channel_mapping=channel_mapping,
-                                         parent_loop=program,
+                                         program_builder=program_builder,
                                          to_single_waveform=set(),
                                          global_transformation=None)
 
@@ -278,12 +283,12 @@ def test_create_program_invalid_measurement_mapping(self) -> None:
             flt._internal_create_program(scope=invalid_scope,
                                          measurement_mapping=measurement_mapping,
                                          channel_mapping=channel_mapping,
-                                         parent_loop=program,
+                                         program_builder=program_builder,
                                          to_single_waveform=set(),
                                          global_transformation=None)
 
     def test_create_program_missing_params(self) -> None:
-        dt = DummyPulseTemplate(parameter_names={'i'}, waveform=DummyWaveform(duration=4.0), duration='t', measurements=[('b', 2, 1)])
+        dt = DummyPulseTemplate(parameter_names={'i'}, waveform=DummyWaveform(duration=4.0), measurements=[('b', 2, 1)])
         flt = ForLoopPulseTemplate(body=dt, loop_index='i', loop_range=('a', 'b', 'c'),
                                    measurements=[('A', 'alph', 1)], parameter_constraints=['c > 1'])
 
@@ -293,13 +298,14 @@ def test_create_program_missing_params(self) -> None:
 
         children = [Loop(waveform=DummyWaveform(duration=2.0))]
         program = Loop(children=children)
+        program_builder = LoopBuilder._testing_dummy([program])
 
         # test parameter in constraints
         with self.assertRaises(ParameterNotProvidedException):
             flt._internal_create_program(scope=scope,
                                          measurement_mapping=measurement_mapping,
                                          channel_mapping=channel_mapping,
-                                         parent_loop=program,
+                                         program_builder=program_builder,
                                          to_single_waveform=set(),
                                          global_transformation=None)
 
@@ -309,17 +315,7 @@ def test_create_program_missing_params(self) -> None:
             flt._internal_create_program(scope=scope,
                                          measurement_mapping=measurement_mapping,
                                          channel_mapping=channel_mapping,
-                                         parent_loop=program,
-                                         to_single_waveform=set(),
-                                         global_transformation=None)
-
-        # test parameter in duration
-        scope = DictScope.from_kwargs(a=1, b=4, c=2, alph=0)
-        with self.assertRaises(ParameterNotProvidedException):
-            flt._internal_create_program(scope=scope,
-                                         measurement_mapping=measurement_mapping,
-                                         channel_mapping=channel_mapping,
-                                         parent_loop=program,
+                                         program_builder=program_builder,
                                          to_single_waveform=set(),
                                          global_transformation=None)
 
@@ -338,17 +334,17 @@ def test_create_program_body_none(self) -> None:
         measurement_mapping = dict(A='B', b='b')
         channel_mapping = dict(C='D')
 
-        program = Loop()
+        program_builder = LoopBuilder()
+
         flt._internal_create_program(scope=scope,
                                      measurement_mapping=measurement_mapping,
                                      channel_mapping=channel_mapping,
-                                     parent_loop=program,
+                                     program_builder=program_builder,
                                      to_single_waveform=set(),
                                      global_transformation=None)
 
-        self.assertEqual(0, len(program.children))
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual([], list(program.children))
+        program = program_builder.to_program()
+        self.assertIsNone(program)
 
     def test_create_program(self) -> None:
         dt = DummyPulseTemplate(parameter_names={'i'},
@@ -365,7 +361,7 @@ def test_create_program(self) -> None:
         to_single_waveform = {'tom', 'jerry'}
         global_transformation = TransformationStub()
 
-        program = Loop()
+        program_builder = LoopBuilder()
 
         # inner _create_program does nothing
         expected_program = Loop(measurements=[('B', .1, 1)])
@@ -374,7 +370,7 @@ def test_create_program(self) -> None:
                                               channel_mapping=channel_mapping,
                                               global_transformation=global_transformation,
                                               to_single_waveform=to_single_waveform,
-                                              parent_loop=program)
+                                              program_builder=program_builder)
         expected_create_program_calls = [mock.call(**expected_create_program_kwargs,
                                                    scope=_ForLoopScope(scope, 'i', i))
                                          for i in (1, 3)]
@@ -386,7 +382,7 @@ def test_create_program(self) -> None:
                     flt._internal_create_program(scope=scope,
                                                  measurement_mapping=measurement_mapping,
                                                  channel_mapping=channel_mapping,
-                                                 parent_loop=program,
+                                                 program_builder=program_builder,
                                                  to_single_waveform=to_single_waveform,
                                                  global_transformation=global_transformation)
 
@@ -394,8 +390,6 @@ def test_create_program(self) -> None:
                     get_measurement_windows.assert_called_once_with(scope, measurement_mapping)
                     self.assertEqual(body_create_program.call_args_list, expected_create_program_calls)
 
-        self.assertEqual(expected_program, program)
-
     def test_create_program_append(self) -> None:
         dt = DummyPulseTemplate(parameter_names={'i'}, waveform=DummyWaveform(duration=4.0), duration=4,
                                 measurements=[('b', 2, 1)])
@@ -408,12 +402,17 @@ def test_create_program_append(self) -> None:
 
         children = [Loop(waveform=DummyWaveform(duration=2.0))]
         program = Loop(children=children)
+
+        program_builder = LoopBuilder._testing_dummy([program])
+
         flt._internal_create_program(scope=scope,
                                      measurement_mapping=measurement_mapping,
                                      channel_mapping=channel_mapping,
-                                     parent_loop=program,
                                      to_single_waveform=set(),
-                                     global_transformation=None)
+                                     global_transformation=None,
+                                     program_builder=program_builder)
+
+        program_builder.to_program()
 
         self.assertEqual(3, len(program.children))
         self.assertIs(children[0], program.children[0])
diff --git a/tests/pulses/mapping_pulse_template_tests.py b/tests/pulses/mapping_pulse_template_tests.py
index 366360d97..c740db0ef 100644
--- a/tests/pulses/mapping_pulse_template_tests.py
+++ b/tests/pulses/mapping_pulse_template_tests.py
@@ -8,7 +8,8 @@
     AmbiguousMappingException, MappingCollisionException
 from qupulse.pulses.parameters import ParameterConstraintViolation, ParameterConstraint, ParameterNotProvidedException
 from qupulse.expressions import Expression
-from qupulse._program._loop import Loop
+from qupulse.program import default_program_builder
+from qupulse.program.loop import Loop, LoopBuilder
 
 from tests.pulses.sequencing_dummies import DummyPulseTemplate, MeasurementWindowTestCase, DummyWaveform
 from tests.serialization_tests import SerializableTests
@@ -140,9 +141,6 @@ def test_from_tuple_partial_mappings(self):
                                          measurement_mapping={'m1': 'n1'},
                                          channel_mapping={'c1': 'd1'})
 
-
-
-
     def test_external_params(self):
         template = DummyPulseTemplate(parameter_names={'foo', 'bar'})
         st = MappingPulseTemplate(template, parameter_mapping={'foo': 't*k', 'bar': 't*l'})
@@ -315,13 +313,13 @@ def test_create_program(self) -> None:
         pre_measurement_mapping = {'meas2': 'meas3'}
         pre_channel_mapping = {'default': 'A'}
 
-        program = Loop()
+        program_builder = default_program_builder()
         expected_inner_args = dict(scope=st.map_scope(pre_scope),
                                    measurement_mapping=st.get_updated_measurement_mapping(pre_measurement_mapping),
                                    channel_mapping=st.get_updated_channel_mapping(pre_channel_mapping),
                                    to_single_waveform=to_single_waveform,
                                    global_transformation=global_transformation,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
 
         with mock.patch.object(template, '_create_program') as inner_create_program:
             st._internal_create_program(scope=pre_scope,
@@ -329,11 +327,11 @@ def test_create_program(self) -> None:
                                         channel_mapping=pre_channel_mapping,
                                         to_single_waveform=to_single_waveform,
                                         global_transformation=global_transformation,
-                                        parent_loop=program)
+                                        program_builder=program_builder)
             inner_create_program.assert_called_once_with(**expected_inner_args)
 
         # as we mock the inner function there shouldnt be any changes
-        self.assertEqual(program, Loop())
+        self.assertIsNone(program_builder.to_program())
 
     def test_create_program_invalid_measurement_mapping(self) -> None:
         measurement_mapping = {'meas1': 'meas2'}
@@ -352,14 +350,14 @@ def test_create_program_invalid_measurement_mapping(self) -> None:
         pre_measurement_mapping = {}
         pre_channel_mapping = {'default': 'A'}
 
-        program = Loop()
+        program_builder = default_program_builder()
         with self.assertRaises(KeyError):
             st._internal_create_program(scope=pre_scope,
                                         measurement_mapping=pre_measurement_mapping,
                                         channel_mapping=pre_channel_mapping,
                                         to_single_waveform=set(),
                                         global_transformation=None,
-                                        parent_loop=program)
+                                        program_builder=program_builder)
 
     def test_create_program_parameter_constraint_violation(self) -> None:
         measurement_mapping = {'meas1': 'meas2'}
@@ -379,14 +377,14 @@ def test_create_program_parameter_constraint_violation(self) -> None:
         pre_measurement_mapping = {'meas2': 'meas3'}
         pre_channel_mapping = {'default': 'A'}
 
-        program = Loop()
+        program_builder = default_program_builder()
         with self.assertRaises(ParameterConstraintViolation):
             st._internal_create_program(scope=pre_scope,
                                         measurement_mapping=pre_measurement_mapping,
                                         channel_mapping=pre_channel_mapping,
                                         to_single_waveform=set(),
                                         global_transformation=None,
-                                        parent_loop=program)
+                                        program_builder=program_builder)
 
     def test_create_program_subtemplate_none(self) -> None:
         measurement_mapping = {'meas1': 'meas2'}
@@ -406,24 +404,22 @@ def test_create_program_subtemplate_none(self) -> None:
         pre_measurement_mapping = {'meas2': 'meas3'}
         pre_channel_mapping = {'default': 'A'}
 
-        program = Loop()
+        program_builder = LoopBuilder()
         st._internal_create_program(scope=pre_scope,
                                     measurement_mapping=pre_measurement_mapping,
                                     channel_mapping=pre_channel_mapping,
                                     to_single_waveform=set(),
                                     global_transformation=None,
-                                    parent_loop=program)
+                                    program_builder=program_builder)
 
         self.assertEqual(1, len(template.create_program_calls))
         self.assertEqual((st.map_scope(pre_scope),
                           st.get_updated_measurement_mapping(pre_measurement_mapping),
                           st.get_updated_channel_mapping(pre_channel_mapping),
-                          program),
+                          program_builder),
                          template.create_program_calls[-1])
 
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual(0, len(program.children))
-        self.assertIsNone(program._measurements)
+        self.assertIsNone(program_builder.to_program())
 
     def test_same_channel_error(self):
 
diff --git a/tests/pulses/multi_channel_pulse_template_tests.py b/tests/pulses/multi_channel_pulse_template_tests.py
index 87ae72575..617ccb935 100644
--- a/tests/pulses/multi_channel_pulse_template_tests.py
+++ b/tests/pulses/multi_channel_pulse_template_tests.py
@@ -414,7 +414,7 @@ def test_internal_create_program(self):
                                       measurement_names={'M'}, waveform=DummyWaveform())
         overwritten_channels = {'Y': 'c', 'Z': 'a', 'ToNone': 'foo'}
 
-        parent_loop = object()
+        program_builder = object()
         measurement_mapping = object()
         channel_mapping = {'Y': 'O', 'Z': 'Z', 'X': 'X', 'ToNone': None}
         to_single_waveform = object()
@@ -422,7 +422,7 @@ def test_internal_create_program(self):
         other_kwargs = dict(measurement_mapping=measurement_mapping,
                             channel_mapping=channel_mapping,
                             to_single_waveform=to_single_waveform,
-                            parent_loop=parent_loop)
+                            program_builder=program_builder)
         pccpt = ParallelChannelPulseTemplate(template, overwritten_channels)
 
         scope = DictScope.from_kwargs(c=1.2, a=3.4)
diff --git a/tests/pulses/plotting_tests.py b/tests/pulses/plotting_tests.py
index 0e96a2c90..ea3765c89 100644
--- a/tests/pulses/plotting_tests.py
+++ b/tests/pulses/plotting_tests.py
@@ -9,11 +9,17 @@
 from qupulse.plotting import PlottingNotPossibleException, render, plot
 from qupulse.pulses.table_pulse_template import TablePulseTemplate
 from qupulse.pulses.sequence_pulse_template import SequencePulseTemplate
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 
 from tests.pulses.sequencing_dummies import DummyWaveform, DummyPulseTemplate
 
 
+def use_svg_backend():
+    import matplotlib.pyplot
+    matplotlib.pyplot.close('all')
+    matplotlib.use('svg')
+
+
 class PlotterTests(unittest.TestCase):
     def test_render_loop_sliced(self) -> None:
         wf = DummyWaveform(duration=19)
@@ -87,17 +93,18 @@ def integrated_test_with_sequencer_and_pulse_templates(self) -> None:
         self.assertEqual(expected_voltages, voltages)
 
     def test_plot_empty_pulse(self) -> None:
-        import matplotlib
-        matplotlib.use('svg') # use non-interactive backend so that test does not fail on travis
+        # use non-interactive backend so that test does not fail on travis
+        use_svg_backend()
 
         pt = DummyPulseTemplate()
         with self.assertWarnsRegex(UserWarning, "empty", msg="plot() did not issue a warning for an empty pulse"):
             plot(pt, dict(), show=False)
 
     def test_plot_pulse_automatic_sample_rate(self) -> None:
-        import matplotlib
-        matplotlib.use('svg') # use non-interactive backend so that test does not fail on travis
-        pt=ConstantPT(100, {'a': 1})
+        # use non-interactive backend so that test does not fail on travis
+        use_svg_backend()
+
+        pt = ConstantPT(100, {'a': 1})
         plot(pt, sample_rate=None)
 
     def test_bug_447(self):
@@ -132,8 +139,8 @@ def test(self) -> None:
 class PlottingIsinstanceTests(unittest.TestCase):
     @unittest.skip("Breaks other tests")
     def test_bug_422(self):
-        import matplotlib
-        matplotlib.use('svg')  # use non-interactive backend so that test does not fail on travis
+        # use non-interactive backend so that test does not fail on travis
+        use_svg_backend()
 
         to_reload = ['qupulse._program._loop',
                      'qupulse.pulses.pulse_template',
diff --git a/tests/pulses/pulse_template_tests.py b/tests/pulses/pulse_template_tests.py
index d9919b10c..ad82dba9f 100644
--- a/tests/pulses/pulse_template_tests.py
+++ b/tests/pulses/pulse_template_tests.py
@@ -3,6 +3,8 @@
 from unittest import mock
 
 from typing import Optional, Dict, Set, Any, Union
+
+import frozendict
 import sympy
 
 from qupulse.parameter_scope import Scope, DictScope
@@ -10,9 +12,10 @@
 from qupulse.expressions import Expression, ExpressionScalar
 from qupulse.pulses import ConstantPT, FunctionPT, RepetitionPT, ForLoopPT, ParallelChannelPT, MappingPT,\
     TimeReversalPT, AtomicMultiChannelPT
-from qupulse.pulses.pulse_template import AtomicPulseTemplate, PulseTemplate
+from qupulse.pulses.pulse_template import AtomicPulseTemplate, PulseTemplate, UnknownVolatileParameter
 from qupulse.pulses.multi_channel_pulse_template import MultiChannelWaveform
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
+from qupulse.program import ProgramBuilder, default_program_builder
 
 from qupulse._program.transformation import Transformation
 from qupulse._program.waveforms import TransformingWaveform
@@ -20,15 +23,19 @@
 from tests.pulses.sequencing_dummies import DummyWaveform
 from tests._program.transformation_tests import TransformationStub
 
+from qupulse.program.loop import LoopBuilder
+
 
 class PulseTemplateStub(PulseTemplate):
     """All abstract methods are stubs that raise NotImplementedError to catch unexpected calls. If a method is needed in
-    a test one should use mock.patch or mock.patch.object"""
+    a test one should use mock.patch or mock.patch.object.
+    Properties can be passed as init argument because mocking them is a pita."""
     def __init__(self, identifier=None,
                  defined_channels=None,
                  duration=None,
                  parameter_names=None,
                  measurement_names=None,
+                 final_values=None,
                  registry=None):
         super().__init__(identifier=identifier)
 
@@ -36,6 +43,7 @@ def __init__(self, identifier=None,
         self._duration = duration
         self._parameter_names = parameter_names
         self._measurement_names = set() if measurement_names is None else measurement_names
+        self._final_values = final_values
         self.internal_create_program_args = []
         self._register(registry=registry)
 
@@ -89,17 +97,20 @@ def initial_values(self) -> Dict[ChannelID, ExpressionScalar]:
 
     @property
     def final_values(self) -> Dict[ChannelID, ExpressionScalar]:
-        raise NotImplementedError()
+        if self._final_values is None:
+            raise NotImplementedError()
+        else:
+            return self._final_values
 
 
 def get_appending_internal_create_program(waveform=DummyWaveform(),
                                           always_append=False,
                                           measurements: list=None):
-    def internal_create_program(*, scope, parent_loop: Loop, **_):
+    def internal_create_program(*, scope, program_builder: ProgramBuilder, **_):
         if always_append or 'append_a_child' in scope:
             if measurements is not None:
-                parent_loop.add_measurements(measurements=measurements)
-            parent_loop.append_child(waveform=waveform)
+                program_builder.measure(measurements=measurements)
+            program_builder.play_arbitrary_waveform(waveform=waveform)
 
     return internal_create_program
 
@@ -175,16 +186,19 @@ def test_create_program(self) -> None:
         dummy_waveform = DummyWaveform()
         expected_program = Loop(children=[Loop(waveform=dummy_waveform)])
 
+        program_builder = LoopBuilder()
+
         with mock.patch.object(template,
                                '_create_program',
                                wraps=get_appending_internal_create_program(dummy_waveform)) as _create_program:
-            program = template.create_program(parameters=parameters,
-                                              measurement_mapping=measurement_mapping,
-                                              channel_mapping=channel_mapping,
-                                              to_single_waveform=to_single_waveform,
-                                              global_transformation=global_transformation,
-                                              volatile=volatile)
-            _create_program.assert_called_once_with(**expected_internal_kwargs, parent_loop=program)
+            with mock.patch('qupulse.pulses.pulse_template.default_program_builder', return_value=program_builder):
+                program = template.create_program(parameters=parameters,
+                                                  measurement_mapping=measurement_mapping,
+                                                  channel_mapping=channel_mapping,
+                                                  to_single_waveform=to_single_waveform,
+                                                  global_transformation=global_transformation,
+                                                  volatile=volatile)
+            _create_program.assert_called_once_with(**expected_internal_kwargs, program_builder=program_builder)
         self.assertEqual(expected_program, program)
         self.assertEqual(previos_measurement_mapping, measurement_mapping)
         self.assertEqual(previous_channel_mapping, channel_mapping)
@@ -196,7 +210,7 @@ def test__create_program(self):
         channel_mapping = {'B': 'A'}
         global_transformation = TransformationStub()
         to_single_waveform = {'voll', 'toggo'}
-        parent_loop = Loop()
+        program_builder = LoopBuilder()
 
         template = PulseTemplateStub()
         with mock.patch.object(template, '_internal_create_program') as _internal_create_program:
@@ -205,7 +219,7 @@ def test__create_program(self):
                                      channel_mapping=channel_mapping,
                                      global_transformation=global_transformation,
                                      to_single_waveform=to_single_waveform,
-                                     parent_loop=parent_loop)
+                                     program_builder=program_builder)
 
             _internal_create_program.assert_called_once_with(
                 scope=scope,
@@ -213,9 +227,9 @@ def test__create_program(self):
                 channel_mapping=channel_mapping,
                 global_transformation=global_transformation,
                 to_single_waveform=to_single_waveform,
-                parent_loop=parent_loop)
+                program_builder=program_builder)
 
-            self.assertEqual(parent_loop, Loop())
+            self.assertIsNone(program_builder.to_program())
 
             with self.assertRaisesRegex(NotImplementedError, "volatile"):
                 template._parameter_names = {'c'}
@@ -224,7 +238,7 @@ def test__create_program(self):
                                          channel_mapping=channel_mapping,
                                          global_transformation=global_transformation,
                                          to_single_waveform={template},
-                                         parent_loop=parent_loop)
+                                         program_builder=program_builder)
 
     def test__create_program_single_waveform(self):
         template = PulseTemplateStub(identifier='pt_identifier', parameter_names={'alpha'})
@@ -234,7 +248,9 @@ def test__create_program_single_waveform(self):
                 scope = DictScope.from_kwargs(a=1., b=2., volatile={'a'})
                 measurement_mapping = {'M': 'N'}
                 channel_mapping = {'B': 'A'}
-                parent_loop = Loop()
+
+                program_builder = LoopBuilder()
+                inner_program_builder = LoopBuilder()
 
                 wf = DummyWaveform()
                 single_waveform = DummyWaveform()
@@ -256,28 +272,30 @@ def test__create_program_single_waveform(self):
 
                 with mock.patch.object(template, '_internal_create_program',
                                        wraps=appending_create_program) as _internal_create_program:
-                    with mock.patch('qupulse.pulses.pulse_template.to_waveform',
+                    with mock.patch('qupulse.program.loop.to_waveform',
                                     return_value=single_waveform) as to_waveform:
-                        template._create_program(scope=scope,
-                                                 measurement_mapping=measurement_mapping,
-                                                 channel_mapping=channel_mapping,
-                                                 global_transformation=global_transformation,
-                                                 to_single_waveform=to_single_waveform,
-                                                 parent_loop=parent_loop)
+                        with mock.patch('qupulse.program.loop.LoopBuilder', return_value=inner_program_builder):
+                            template._create_program(scope=scope,
+                                                     measurement_mapping=measurement_mapping,
+                                                     channel_mapping=channel_mapping,
+                                                     global_transformation=global_transformation,
+                                                     to_single_waveform=to_single_waveform,
+                                                     program_builder=program_builder)
 
                         _internal_create_program.assert_called_once_with(scope=scope,
                                                                          measurement_mapping=measurement_mapping,
                                                                          channel_mapping=channel_mapping,
                                                                          global_transformation=None,
                                                                          to_single_waveform=to_single_waveform,
-                                                                         parent_loop=expected_inner_program)
+                                                                         program_builder=inner_program_builder)
 
                         to_waveform.assert_called_once_with(expected_inner_program)
 
-                        expected_program._measurements = set(expected_program._measurements)
-                        parent_loop._measurements = set(parent_loop._measurements)
+                        program = program_builder.to_program()
 
-                        self.assertEqual(expected_program, parent_loop)
+                        expected_program._measurements = set(expected_program._measurements)
+                        program._measurements = set(program._measurements)
+                        self.assertEqual(expected_program, program)
 
     def test_create_program_defaults(self) -> None:
         template = PulseTemplateStub(defined_channels={'A', 'B'}, parameter_names={'foo'}, measurement_names={'hugo', 'foo'})
@@ -290,12 +308,15 @@ def test_create_program_defaults(self) -> None:
 
         dummy_waveform = DummyWaveform()
         expected_program = Loop(children=[Loop(waveform=dummy_waveform)])
+        program_builder = LoopBuilder()
 
         with mock.patch.object(template,
                                '_internal_create_program',
                                wraps=get_appending_internal_create_program(dummy_waveform, True)) as _internal_create_program:
-            program = template.create_program()
-            _internal_create_program.assert_called_once_with(**expected_internal_kwargs, parent_loop=program)
+            with mock.patch('qupulse.pulses.pulse_template.default_program_builder', return_value=program_builder) as pb:
+                program = template.create_program()
+            pb.assert_called_once_with()
+            _internal_create_program.assert_called_once_with(**expected_internal_kwargs, program_builder=program_builder)
         self.assertEqual(expected_program, program)
 
     def test_create_program_channel_mapping(self):
@@ -307,12 +328,119 @@ def test_create_program_channel_mapping(self):
                                         global_transformation=None,
                                         to_single_waveform=set())
 
+        with mock.patch('qupulse.pulses.pulse_template.default_program_builder') as pb:
+            with mock.patch.object(template, '_internal_create_program') as _internal_create_program:
+                template.create_program(channel_mapping={'A': 'C'})
+            pb.assert_called_once_with()
+            _internal_create_program.assert_called_once_with(**expected_internal_kwargs, program_builder=pb.return_value)
+
+    def test_create_program_volatile(self):
+        template = PulseTemplateStub(defined_channels={'A', 'B'})
+
+        parameters = {'abc': 1.}
+
+        expected_internal_kwargs = dict(scope=DictScope.from_kwargs(volatile={'abc'}, **parameters),
+                                        measurement_mapping=dict(),
+                                        channel_mapping={'A': 'A', 'B': 'B'},
+                                        global_transformation=None,
+                                        to_single_waveform=set())
+
+        with mock.patch.object(template, '_internal_create_program') as _internal_create_program:
+            program_builder = default_program_builder()
+            with mock.patch('qupulse.pulses.pulse_template.default_program_builder', return_value=program_builder):
+                template.create_program(parameters=parameters, volatile='abc')
+
+            _internal_create_program.assert_called_once_with(**expected_internal_kwargs, program_builder=program_builder)
         with mock.patch.object(template, '_internal_create_program') as _internal_create_program:
-            template.create_program(channel_mapping={'A': 'C'})
+            program_builder = default_program_builder()
+            with mock.patch('qupulse.pulses.pulse_template.default_program_builder', return_value=program_builder):
+                template.create_program(parameters=parameters, volatile={'abc'})
+
+            _internal_create_program.assert_called_once_with(**expected_internal_kwargs, program_builder=program_builder)
 
-            _internal_create_program.assert_called_once_with(**expected_internal_kwargs, parent_loop=Loop())
+        expected_internal_kwargs = dict(scope=DictScope.from_kwargs(volatile={'abc', 'dfg'}, **parameters),
+                                        measurement_mapping=dict(),
+                                        channel_mapping={'A': 'A', 'B': 'B'},
+                                        global_transformation=None,
+                                        to_single_waveform=set())
 
-    def test_create_program_none(self) -> None:
+        program_builder = default_program_builder()
+        with mock.patch('qupulse.pulses.pulse_template.default_program_builder', return_value=program_builder):
+            with mock.patch.object(template, '_internal_create_program') as _internal_create_program:
+                with self.assertWarns(UnknownVolatileParameter):
+                    template.create_program(parameters=parameters, volatile={'abc', 'dfg'})
+        _internal_create_program.assert_called_once_with(**expected_internal_kwargs, program_builder=program_builder)
+
+    def test_pad_to(self):
+        from qupulse.pulses import SequencePT
+
+        def to_multiple_of_192(x: Expression) -> Expression:
+            return (x + 191) // 192 * 192
+
+        final_values = frozendict.frozendict({'A': ExpressionScalar(0.1), 'B': ExpressionScalar('a')})
+        measurements = [('M', 0, 'y')]
+
+        pt = PulseTemplateStub(duration=ExpressionScalar(10))
+        padded = pt.pad_to(10)
+        self.assertIs(pt, padded)
+
+        pt = PulseTemplateStub(duration=ExpressionScalar('duration'))
+        padded = pt.pad_to('duration')
+        self.assertIs(pt, padded)
+
+        # padding with numeric durations
+
+        pt = PulseTemplateStub(duration=ExpressionScalar(10),
+                               final_values=final_values,
+                               defined_channels=final_values.keys())
+        padded = pt.pad_to(20)
+        self.assertEqual(padded.duration, 20)
+        self.assertEqual(padded.final_values, final_values)
+        self.assertIsInstance(padded, SequencePT)
+        self.assertIs(padded.subtemplates[0], pt)
+
+        padded = pt.pad_to(20, pt_kwargs=dict(measurements=measurements))
+        self.assertEqual(padded.duration, 20)
+        self.assertEqual(padded.final_values, final_values)
+        self.assertIsInstance(padded, SequencePT)
+        self.assertIs(padded.subtemplates[0], pt)
+        self.assertEqual(measurements, padded.measurement_declarations)
+
+        padded = pt.pad_to(10, pt_kwargs=dict(measurements=measurements))
+        self.assertEqual(padded.duration, 10)
+        self.assertEqual(padded.final_values, final_values)
+        self.assertIsInstance(padded, SequencePT)
+        self.assertIs(padded.subtemplates[0], pt)
+        self.assertEqual(measurements, padded.measurement_declarations)
+
+        # padding with numeric duation and callable
+        padded = pt.pad_to(to_multiple_of_192)
+        self.assertEqual(padded.duration, 192)
+        self.assertEqual(padded.final_values, final_values)
+        self.assertIsInstance(padded, SequencePT)
+        self.assertIs(padded.subtemplates[0], pt)
+
+        # padding with symbolic durations
+
+        pt = PulseTemplateStub(duration=ExpressionScalar('duration'),
+                               final_values=final_values,
+                               defined_channels=final_values.keys())
+        padded = pt.pad_to('new_duration')
+        self.assertEqual(padded.duration, 'new_duration')
+        self.assertEqual(padded.final_values, final_values)
+        self.assertIsInstance(padded, SequencePT)
+        self.assertIs(padded.subtemplates[0], pt)
+
+        # padding symbolic durations with callable
+
+        padded = pt.pad_to(to_multiple_of_192)
+        self.assertEqual(padded.duration, '(duration + 191) // 192 * 192')
+        self.assertEqual(padded.final_values, final_values)
+        self.assertIsInstance(padded, SequencePT)
+        self.assertIs(padded.subtemplates[0], pt)
+
+    @mock.patch('qupulse.pulses.pulse_template.default_program_builder')
+    def test_create_program_none(self, pb_mock) -> None:
         template = PulseTemplateStub(defined_channels={'A'}, parameter_names={'foo'})
         parameters = {'foo': 2.126, 'bar': -26.2, 'hugo': 'exp(sin(pi/2))'}
         measurement_mapping = {'M': 'N'}
@@ -325,6 +453,7 @@ def test_create_program_none(self) -> None:
                                         channel_mapping=channel_mapping,
                                         global_transformation=None,
                                         to_single_waveform=set())
+        pb_mock.return_value = LoopBuilder()
 
         with mock.patch.object(template,
                                '_internal_create_program') as _internal_create_program:
@@ -332,7 +461,9 @@ def test_create_program_none(self) -> None:
                                               measurement_mapping=measurement_mapping,
                                               channel_mapping=channel_mapping,
                                               volatile=volatile)
-            _internal_create_program.assert_called_once_with(**expected_internal_kwargs, parent_loop=Loop())
+            pb_mock.assert_called_once_with()
+            _internal_create_program.assert_called_once_with(**expected_internal_kwargs,
+                                                             program_builder=pb_mock.return_value)
         self.assertIsNone(program)
 
     def test_matmul(self):
@@ -344,6 +475,11 @@ def test_matmul(self):
             self.assertEqual(a @ b, 'concat')
             mock_concatenate.assert_called_once_with(a, b)
 
+    def test_pow(self):
+        pt = PulseTemplateStub()
+        pow_pt = pt ** 5
+        self.assertEqual(pow_pt, pt.with_repetition(5))
+
     def test_rmatmul(self):
         a = PulseTemplateStub()
         b = (1, 2, 3)
@@ -418,7 +554,7 @@ def test_internal_create_program(self) -> None:
         scope = DictScope.from_kwargs(foo=7.2, volatile={'gutes_zeuch'})
         measurement_mapping = {'M': 'N'}
         channel_mapping = {'B': 'A'}
-        program = Loop()
+        program_builder = LoopBuilder()
 
         expected_program = Loop(children=[Loop(waveform=wf)],
                                 measurements=[('N', 0, 5)])
@@ -427,20 +563,17 @@ def test_internal_create_program(self) -> None:
             template._internal_create_program(scope=scope,
                                               measurement_mapping=measurement_mapping,
                                               channel_mapping=channel_mapping,
-                                              parent_loop=program,
+                                              program_builder=program_builder,
                                               to_single_waveform=set(),
                                               global_transformation=None)
             build_waveform.assert_called_once_with(parameters=scope, channel_mapping=channel_mapping)
-
+        program = program_builder.to_program()
         self.assertEqual(expected_program, program)
 
-        # MultiChannelProgram calls cleanup
-        program.cleanup()
-
     def test_internal_create_program_transformation(self):
         inner_wf = DummyWaveform()
         template = AtomicPulseTemplateStub(parameter_names=set())
-        program = Loop()
+        program_builder = LoopBuilder()
         global_transformation = TransformationStub()
         scope = DictScope.from_kwargs()
         expected_program = Loop(children=[Loop(waveform=TransformingWaveform(inner_wf, global_transformation))])
@@ -449,10 +582,10 @@ def test_internal_create_program_transformation(self):
             template._internal_create_program(scope=scope,
                                               measurement_mapping={},
                                               channel_mapping={},
-                                              parent_loop=program,
+                                              program_builder=program_builder,
                                               to_single_waveform=set(),
                                               global_transformation=global_transformation)
-
+        program = program_builder.to_program()
         self.assertEqual(expected_program, program)
 
     def test_internal_create_program_no_waveform(self) -> None:
@@ -462,7 +595,7 @@ def test_internal_create_program_no_waveform(self) -> None:
         scope = DictScope.from_kwargs(foo=3.5, bar=3, volatile={'bar'})
         measurement_mapping = {'M': 'N'}
         channel_mapping = {'B': 'A'}
-        program = Loop()
+        program_builder = LoopBuilder()
 
         expected_program = Loop()
 
@@ -473,13 +606,13 @@ def test_internal_create_program_no_waveform(self) -> None:
                 template._internal_create_program(scope=scope,
                                                   measurement_mapping=measurement_mapping,
                                                   channel_mapping=channel_mapping,
-                                                  parent_loop=program,
+                                                  program_builder=program_builder,
                                                   to_single_waveform=set(),
                                                   global_transformation=None)
                 build_waveform.assert_called_once_with(parameters=scope, channel_mapping=channel_mapping)
                 get_meas_windows.assert_not_called()
 
-        self.assertEqual(expected_program, program)
+        self.assertIsNone(program_builder.to_program())
 
     def test_internal_create_program_volatile(self):
         template = AtomicPulseTemplateStub(parameter_names={'foo'})
@@ -487,13 +620,13 @@ def test_internal_create_program_volatile(self):
         measurement_mapping = {'M': 'N'}
         channel_mapping = {'B': 'A'}
 
-        program = Loop()
+        program_builder = LoopBuilder()
 
         with self.assertRaisesRegex(AssertionError, "volatile"):
             template._internal_create_program(scope=scope,
                                               measurement_mapping=measurement_mapping,
                                               channel_mapping=channel_mapping,
-                                              parent_loop=program,
+                                              program_builder=program_builder,
                                               to_single_waveform=set(),
                                               global_transformation=None)
-        self.assertEqual(Loop(), program)
+        self.assertIsNone(program_builder.to_program())
diff --git a/tests/pulses/repetition_pulse_template_tests.py b/tests/pulses/repetition_pulse_template_tests.py
index b59cef820..ff399682d 100644
--- a/tests/pulses/repetition_pulse_template_tests.py
+++ b/tests/pulses/repetition_pulse_template_tests.py
@@ -5,7 +5,8 @@
 from qupulse.parameter_scope import Scope, DictScope
 from qupulse.utils.types import FrozenDict
 
-from qupulse._program._loop import Loop
+from qupulse.program import default_program_builder
+from qupulse.program.loop import Loop, LoopBuilder
 from qupulse.expressions import Expression, ExpressionScalar
 from qupulse.pulses import ConstantPT
 from qupulse.pulses.repetition_pulse_template import RepetitionPulseTemplate,ParameterNotIntegerException
@@ -118,7 +119,7 @@ def test_internal_create_program(self):
         global_transformation = TransformationStub()
         to_single_waveform = {'to', 'single', 'waveform'}
 
-        program = Loop()
+        program_builder = LoopBuilder()
         expected_program = Loop(children=[Loop(children=[Loop(waveform=wf)], repetition_count=6)],
                                 measurements=[('l', .1, .2)])
 
@@ -134,7 +135,8 @@ def test_internal_create_program(self):
                                                      channel_mapping=channel_mapping,
                                                      global_transformation=global_transformation,
                                                      to_single_waveform=to_single_waveform,
-                                                     parent_loop=program)
+                                                     program_builder=program_builder)
+                        program = program_builder.to_program()
 
                         self.assertEqual(program, expected_program)
                         body_create_program.assert_called_once_with(scope=scope,
@@ -142,7 +144,7 @@ def test_internal_create_program(self):
                                                                     channel_mapping=channel_mapping,
                                                                     global_transformation=global_transformation,
                                                                     to_single_waveform=to_single_waveform,
-                                                                    parent_loop=program.children[0])
+                                                                    program_builder=program_builder)
                         validate_scope.assert_called_once_with(scope)
                         get_repetition_count_value.assert_called_once_with(scope)
                         get_meas.assert_called_once_with(scope, measurement_mapping)
@@ -154,20 +156,22 @@ def test_create_program_constant_success_measurements(self) -> None:
         scope = DictScope.from_mapping({'foo': 8})
         measurement_mapping = {'my': 'thy', 'b': 'b'}
         channel_mapping = {}
-        program = Loop()
+        program_builder = LoopBuilder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
+
+        program = program_builder.to_program()
 
         self.assertEqual(1, len(program.children))
         internal_loop = program[0]  # type: Loop
         self.assertEqual(repetitions, internal_loop.repetition_count)
 
         self.assertEqual(1, len(internal_loop))
-        self.assertEqual((scope, measurement_mapping, channel_mapping, internal_loop), body.create_program_calls[-1])
+        self.assertEqual((scope, measurement_mapping, channel_mapping, program_builder), body.create_program_calls[-1])
         self.assertEqual(body.waveform, internal_loop[0].waveform)
 
         self.assert_measurement_windows_equal({'b': ([0, 2, 4], [1, 1, 1]), 'thy': ([2], [2])}, program.get_measurement_windows())
@@ -185,13 +189,14 @@ def test_create_program_declaration_success(self) -> None:
         scope = DictScope.from_kwargs(foo=3)
         measurement_mapping = dict(moth='fire')
         channel_mapping = dict(asd='f')
-        program = Loop()
+        program_builder = LoopBuilder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
+        program = program_builder.to_program()
 
         self.assertEqual(1, program.repetition_count)
         self.assertEqual(1, len(program.children))
@@ -199,7 +204,7 @@ def test_create_program_declaration_success(self) -> None:
         self.assertEqual(scope[repetitions], internal_loop.repetition_count)
 
         self.assertEqual(1, len(internal_loop))
-        self.assertEqual((scope, measurement_mapping, channel_mapping, internal_loop),
+        self.assertEqual((scope, measurement_mapping, channel_mapping, program_builder),
                          body.create_program_calls[-1])
         self.assertEqual(body.waveform, internal_loop[0].waveform)
 
@@ -219,13 +224,14 @@ def test_create_program_declaration_success_appended_measurements(self) -> None:
         channel_mapping = dict(asd='f')
         children = [Loop(waveform=DummyWaveform(duration=0))]
         program = Loop(children=children, measurements=[('a', [0], [1])], repetition_count=2)
+        program_builder = LoopBuilder._testing_dummy([program])
 
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
 
         self.assertEqual(2, program.repetition_count)
         self.assertEqual(2, len(program.children))
@@ -234,7 +240,7 @@ def test_create_program_declaration_success_appended_measurements(self) -> None:
         self.assertEqual(scope[repetitions], internal_loop.repetition_count)
 
         self.assertEqual(1, len(internal_loop))
-        self.assertEqual((scope, measurement_mapping, channel_mapping, internal_loop), body.create_program_calls[-1])
+        self.assertEqual((scope, measurement_mapping, channel_mapping, program_builder), body.create_program_calls[-1])
         self.assertEqual(body.waveform, internal_loop[0].waveform)
 
         self.assert_measurement_windows_equal({'fire': ([0, 6], [7.1, 7.1]),
@@ -251,13 +257,14 @@ def test_create_program_declaration_success_measurements(self) -> None:
         scope = DictScope.from_kwargs(foo=3, meas_end=7.1)
         measurement_mapping = dict(moth='fire', b='b')
         channel_mapping = dict(asd='f')
-        program = Loop()
+        program_builder = LoopBuilder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
+        program = program_builder.to_program()
 
         self.assertEqual(1, program.repetition_count)
         self.assertEqual(1, len(program.children))
@@ -265,7 +272,7 @@ def test_create_program_declaration_success_measurements(self) -> None:
         self.assertEqual(scope[repetitions], internal_loop.repetition_count)
 
         self.assertEqual(1, len(internal_loop))
-        self.assertEqual((scope, measurement_mapping, channel_mapping, internal_loop), body.create_program_calls[-1])
+        self.assertEqual((scope, measurement_mapping, channel_mapping, program_builder), body.create_program_calls[-1])
         self.assertEqual(body.waveform, internal_loop[0].waveform)
 
         self.assert_measurement_windows_equal({'fire': ([0], [7.1]), 'b': ([0, 2, 4], [1, 1, 1])}, program.get_measurement_windows())
@@ -281,13 +288,14 @@ def test_create_program_declaration_exceeds_bounds(self) -> None:
 
         children = [Loop(waveform=DummyWaveform(duration=0))]
         program = Loop(children=children)
+        program_builder = LoopBuilder._testing_dummy([program])
         with self.assertRaises(ParameterConstraintViolation):
             t._internal_create_program(scope=scope,
                                        measurement_mapping=measurement_mapping,
                                        channel_mapping=channel_mapping,
                                        to_single_waveform=set(),
                                        global_transformation=None,
-                                       parent_loop=program)
+                                       program_builder=program_builder)
         self.assertFalse(body.create_program_calls)
         self.assertEqual(1, program.repetition_count)
         self.assertEqual(children, list(program.children))
@@ -303,21 +311,22 @@ def test_create_program_declaration_parameter_not_provided(self) -> None:
         channel_mapping = dict(asd='f')
         children = [Loop(waveform=DummyWaveform(duration=0))]
         program = Loop(children=children)
+        program_builder = LoopBuilder._testing_dummy([program])
         with self.assertRaises(ParameterNotProvidedException):
             t._internal_create_program(scope=scope,
                                        measurement_mapping=measurement_mapping,
                                        channel_mapping=channel_mapping,
-                                   to_single_waveform=set(),
-                                   global_transformation=None,
-                                       parent_loop=program)
+                                       to_single_waveform=set(),
+                                       global_transformation=None,
+                                       program_builder=program_builder)
 
         with self.assertRaises(ParameterNotProvidedException):
             t._internal_create_program(scope=scope,
                                        measurement_mapping=measurement_mapping,
                                        channel_mapping=channel_mapping,
-                                   to_single_waveform=set(),
-                                   global_transformation=None,
-                                       parent_loop=program)
+                                       to_single_waveform=set(),
+                                       global_transformation=None,
+                                       program_builder=program_builder)
 
         self.assertFalse(body.create_program_calls)
         self.assertEqual(1, program.repetition_count)
@@ -334,13 +343,15 @@ def test_create_program_declaration_parameter_value_not_whole(self) -> None:
         channel_mapping = dict(asd='f')
         children = [Loop(waveform=DummyWaveform(duration=0))]
         program = Loop(children=children)
+        program_builder = LoopBuilder._testing_dummy([program])
+
         with self.assertRaises(ParameterNotIntegerException):
             t._internal_create_program(scope=scope,
                                        measurement_mapping=measurement_mapping,
                                        channel_mapping=channel_mapping,
-                                   to_single_waveform=set(),
-                                   global_transformation=None,
-                                       parent_loop=program)
+                                       to_single_waveform=set(),
+                                       global_transformation=None,
+                                       program_builder=program_builder)
         self.assertFalse(body.create_program_calls)
         self.assertEqual(1, program.repetition_count)
         self.assertEqual(children, list(program.children))
@@ -356,13 +367,15 @@ def test_create_program_constant_measurement_mapping_failure(self) -> None:
         channel_mapping = dict(asd='f')
         children = [Loop(waveform=DummyWaveform(duration=0))]
         program = Loop(children=children)
+        program_builder = LoopBuilder._testing_dummy([program])
+
         with self.assertRaises(KeyError):
             t._internal_create_program(scope=scope,
                                        measurement_mapping=measurement_mapping,
                                        channel_mapping=channel_mapping,
                                        to_single_waveform=set(),
                                        global_transformation=None,
-                                       parent_loop=program)
+                                       program_builder=program_builder)
 
         # test for failure on child level
         measurement_mapping = dict(a='a')
@@ -372,7 +385,7 @@ def test_create_program_constant_measurement_mapping_failure(self) -> None:
                                        channel_mapping=channel_mapping,
                                        to_single_waveform=set(),
                                        global_transformation=None,
-                                       parent_loop=program)
+                                       program_builder=program_builder)
         self.assertFalse(body.create_program_calls)
         self.assertEqual(1, program.repetition_count)
         self.assertEqual(children, list(program.children))
@@ -392,17 +405,16 @@ def test_create_program_rep_count_zero_constant(self) -> None:
         measurement_mapping = dict(moth='fire')
         channel_mapping = dict(asd='f')
 
-        program = Loop()
+        program_builder = LoopBuilder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
+        program = program_builder.to_program()
         self.assertFalse(body.create_program_calls)
-        self.assertFalse(program.children)
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual(None, program._measurements)
+        self.assertIsNone(program)
 
     def test_create_program_rep_count_zero_constant_with_measurement(self) -> None:
         repetitions = 0
@@ -417,17 +429,16 @@ def test_create_program_rep_count_zero_constant_with_measurement(self) -> None:
         measurement_mapping = dict(moth='fire')
         channel_mapping = dict(asd='f')
 
-        program = Loop()
+        program_builder = default_program_builder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
+        program = program_builder.to_program()
         self.assertFalse(body.create_program_calls)
-        self.assertFalse(program.children)
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual(None, program._measurements)
+        self.assertIsNone(program)
 
     def test_create_program_rep_count_zero_declaration(self) -> None:
         repetitions = "foo"
@@ -442,17 +453,16 @@ def test_create_program_rep_count_zero_declaration(self) -> None:
         measurement_mapping = dict(moth='fire')
         channel_mapping = dict(asd='f')
 
-        program = Loop()
+        program_builder = default_program_builder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
+        program = program_builder.to_program()
         self.assertFalse(body.create_program_calls)
-        self.assertFalse(program.children)
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual(None, program._measurements)
+        self.assertIsNone(program)
 
     def test_create_program_rep_count_zero_declaration_with_measurement(self) -> None:
         repetitions = "foo"
@@ -467,17 +477,16 @@ def test_create_program_rep_count_zero_declaration_with_measurement(self) -> Non
         measurement_mapping = dict(moth='fire')
         channel_mapping = dict(asd='f')
 
-        program = Loop()
+        program_builder = default_program_builder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
+        program = program_builder.to_program()
         self.assertFalse(body.create_program_calls)
-        self.assertFalse(program.children)
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual(None, program._measurements)
+        self.assertIsNone(program)
 
     def test_create_program_rep_count_neg_declaration(self) -> None:
         repetitions = "foo"
@@ -492,17 +501,16 @@ def test_create_program_rep_count_neg_declaration(self) -> None:
         measurement_mapping = dict(moth='fire')
         channel_mapping = dict(asd='f')
 
-        program = Loop()
+        program_builder = default_program_builder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
+        program = program_builder.to_program()
         self.assertFalse(body.create_program_calls)
-        self.assertFalse(program.children)
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual(None, program._measurements)
+        self.assertIsNone(program)
 
     def test_create_program_rep_count_neg_declaration_with_measurements(self) -> None:
         repetitions = "foo"
@@ -517,17 +525,16 @@ def test_create_program_rep_count_neg_declaration_with_measurements(self) -> Non
         measurement_mapping = dict(moth='fire')
         channel_mapping = dict(asd='f')
 
-        program = Loop()
+        program_builder = default_program_builder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
+                                   program_builder=program_builder)
+        program = program_builder.to_program()
         self.assertFalse(body.create_program_calls)
-        self.assertFalse(program.children)
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual(None, program._measurements)
+        self.assertIsNone(program)
 
     def test_create_program_none_subprogram(self) -> None:
         repetitions = "foo"
@@ -536,16 +543,14 @@ def test_create_program_none_subprogram(self) -> None:
         scope = DictScope.from_kwargs(foo=3)
         measurement_mapping = dict(moth='fire')
         channel_mapping = dict(asd='f')
-        program = Loop()
+        program_builder = default_program_builder()
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
-        self.assertFalse(program.children)
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual(None, program._measurements)
+                                   program_builder=program_builder)
+        self.assertIsNone(program_builder.to_program())
 
     def test_create_program_none_subprogram_with_measurement(self) -> None:
         repetitions = "foo"
@@ -555,17 +560,15 @@ def test_create_program_none_subprogram_with_measurement(self) -> None:
         scope = DictScope.from_kwargs(foo=3, meas_end=7.1)
         measurement_mapping = dict(moth='fire', b='b')
         channel_mapping = dict(asd='f')
-        program = Loop()
+        program_builder = default_program_builder()
 
         t._internal_create_program(scope=scope,
                                    measurement_mapping=measurement_mapping,
                                    channel_mapping=channel_mapping,
                                    to_single_waveform=set(),
                                    global_transformation=None,
-                                   parent_loop=program)
-        self.assertFalse(program.children)
-        self.assertEqual(1, program.repetition_count)
-        self.assertEqual(None, program._measurements)
+                                   program_builder=program_builder)
+        self.assertIsNone(program_builder.to_program())
 
 
 class RepetitionPulseTemplateSerializationTests(SerializableTests, unittest.TestCase):
diff --git a/tests/pulses/sequence_pulse_template_tests.py b/tests/pulses/sequence_pulse_template_tests.py
index 4985fb8de..d2a7f5544 100644
--- a/tests/pulses/sequence_pulse_template_tests.py
+++ b/tests/pulses/sequence_pulse_template_tests.py
@@ -7,7 +7,8 @@
 from qupulse.pulses.sequence_pulse_template import SequencePulseTemplate, SequenceWaveform
 from qupulse.pulses.mapping_pulse_template import MappingPulseTemplate
 from qupulse.pulses.parameters import ParameterConstraint, ParameterConstraintViolation, ParameterNotProvidedException
-from qupulse._program._loop import Loop
+from qupulse.program import default_program_builder
+from qupulse.program.loop import LoopBuilder, Loop
 
 from tests.pulses.sequencing_dummies import DummyPulseTemplate, DummyWaveform, MeasurementWindowTestCase
 from tests.serialization_dummies import DummySerializer
@@ -237,7 +238,7 @@ def test_internal_create_program(self):
                       global_transformation=TransformationStub(),
                       to_single_waveform={'to', 'single', 'waveform'})
 
-        program = Loop()
+        program_builder = LoopBuilder()
 
         expected_program = Loop(children=[Loop(waveform=wfs[0]),
                                           Loop(waveform=wfs[1])],
@@ -251,14 +252,16 @@ def test_internal_create_program(self):
                     mock.patch.object(sub_templates[1], '_create_program',
                                        wraps=get_appending_internal_create_program(wfs[1], True)) as create_1:
 
-                    spt._internal_create_program(**kwargs, parent_loop=program)
+                    spt._internal_create_program(**kwargs, program_builder=program_builder)
+
+                    program = program_builder.to_program()
 
                     self.assertEqual(expected_program, program)
 
                     validate_scope.assert_called_once_with(kwargs['scope'])
                     get_measurement_windows.assert_called_once_with(kwargs['scope'], kwargs['measurement_mapping'])
-                    create_0.assert_called_once_with(**kwargs, parent_loop=program)
-                    create_1.assert_called_once_with(**kwargs, parent_loop=program)
+                    create_0.assert_called_once_with(**kwargs, program_builder=program_builder)
+                    create_1.assert_called_once_with(**kwargs, program_builder=program_builder)
 
     def test_create_program_internal(self) -> None:
         sub1 = DummyPulseTemplate(duration=3, waveform=DummyWaveform(duration=3), measurements=[('b', 1, 2)], defined_channels={'A'})
@@ -267,13 +270,15 @@ def test_create_program_internal(self) -> None:
         measurement_mapping = {'a': 'a', 'b': 'b'}
         channel_mapping = dict()
         seq = SequencePulseTemplate(sub1, sub2, measurements=[('a', 0, 1)])
-        loop = Loop()
+        program_builder = LoopBuilder()
+
         seq._internal_create_program(scope=scope,
                                      measurement_mapping=measurement_mapping,
                                      channel_mapping=channel_mapping,
                                      global_transformation=None,
                                      to_single_waveform=set(),
-                                     parent_loop=loop)
+                                     program_builder=program_builder)
+        loop = program_builder.to_program()
         self.assertEqual(1, loop.repetition_count)
         self.assertIsNone(loop.waveform)
         self.assertEqual([Loop(repetition_count=1, waveform=sub1.waveform),
@@ -283,13 +288,14 @@ def test_create_program_internal(self) -> None:
 
         ### test again with inverted sequence
         seq = SequencePulseTemplate(sub2, sub1, measurements=[('a', 0, 1)])
-        loop = Loop()
+        program_builder = LoopBuilder()
         seq._internal_create_program(scope=scope,
                                      measurement_mapping=measurement_mapping,
                                      channel_mapping=channel_mapping,
                                      global_transformation=None,
                                      to_single_waveform=set(),
-                                     parent_loop=loop)
+                                     program_builder=program_builder)
+        loop = program_builder.to_program()
         self.assertEqual(1, loop.repetition_count)
         self.assertIsNone(loop.waveform)
         self.assertEqual([Loop(repetition_count=1, waveform=sub2.waveform),
@@ -304,6 +310,7 @@ def test_internal_create_program_no_measurement_mapping(self) -> None:
         seq = SequencePulseTemplate(sub1, sub2, measurements=[('a', 0, 1)])
         children = [Loop(waveform=DummyWaveform())]
         loop = Loop(measurements=[], children=children)
+        program_builder = LoopBuilder._testing_dummy([loop])
 
         with self.assertRaises(KeyError):
             seq._internal_create_program(scope=scope,
@@ -311,9 +318,7 @@ def test_internal_create_program_no_measurement_mapping(self) -> None:
                                          channel_mapping=dict(),
                                          global_transformation=None,
                                          to_single_waveform=set(),
-
-                                         parent_loop=loop)
-
+                                         program_builder=program_builder)
         self.assertFalse(sub1.create_program_calls)
         self.assertFalse(sub2.create_program_calls)
         self.assertEqual(children, list(loop.children))
@@ -328,8 +333,7 @@ def test_internal_create_program_no_measurement_mapping(self) -> None:
                                          channel_mapping=dict(),
                                          global_transformation=None,
                                          to_single_waveform=set(),
-
-                                         parent_loop=loop)
+                                         program_builder=program_builder)
 
     def test_internal_create_program_one_child_no_duration(self) -> None:
         sub1 = DummyPulseTemplate(duration=0, waveform=None, measurements=[('b', 1, 2)], defined_channels={'A'})
@@ -338,13 +342,14 @@ def test_internal_create_program_one_child_no_duration(self) -> None:
         measurement_mapping = {'a': 'a', 'b': 'b'}
         channel_mapping = dict()
         seq = SequencePulseTemplate(sub1, sub2, measurements=[('a', 0, 1)])
-        loop = Loop()
+        program_builder = LoopBuilder()
         seq._internal_create_program(scope=scope,
                                      measurement_mapping=measurement_mapping,
                                      channel_mapping=channel_mapping,
                                      global_transformation=None,
                                      to_single_waveform=set(),
-                                     parent_loop=loop)
+                                     program_builder=program_builder)
+        loop = program_builder.to_program()
         self.assertEqual(1, loop.repetition_count)
         self.assertIsNone(loop.waveform)
         self.assertEqual([Loop(repetition_count=1, waveform=sub2.waveform)],
@@ -357,13 +362,14 @@ def test_internal_create_program_one_child_no_duration(self) -> None:
 
         ### test again with inverted sequence
         seq = SequencePulseTemplate(sub2, sub1, measurements=[('a', 0, 1)])
-        loop = Loop()
+        program_builder = LoopBuilder()
         seq._internal_create_program(scope=scope,
                                      measurement_mapping=measurement_mapping,
                                      channel_mapping=channel_mapping,
                                      global_transformation=None,
                                      to_single_waveform=set(),
-                                     parent_loop=loop)
+                                     program_builder=program_builder)
+        loop = program_builder.to_program()
         self.assertEqual(1, loop.repetition_count)
         self.assertIsNone(loop.waveform)
         self.assertEqual([Loop(repetition_count=1, waveform=sub2.waveform)],
@@ -382,68 +388,58 @@ def test_internal_create_program_both_children_no_duration(self) -> None:
         channel_mapping = dict()
 
         seq = SequencePulseTemplate(sub1, sub2, measurements=[('a', 0, 1)])
-        loop = Loop(measurements=None)
+        program_builder = default_program_builder()
         seq._internal_create_program(scope=scope,
                                      measurement_mapping=measurement_mapping,
                                      channel_mapping=channel_mapping,
                                      global_transformation=None,
                                      to_single_waveform=set(),
-                                     parent_loop=loop)
-        self.assertEqual(1, loop.repetition_count)
-        self.assertIsNone(loop.waveform)
-        self.assertEqual([], list(loop.children))
-        self.assertIsNone(loop._measurements)
+                                     program_builder=program_builder)
+        self.assertIsNone(program_builder.to_program())
 
     def test_internal_create_program_parameter_constraint_violations(self) -> None:
         sub1 = DummyPulseTemplate(duration=3, waveform=DummyWaveform(duration=3), measurements=[('b', 1, 2)])
         sub2 = DummyPulseTemplate(duration=2, waveform=DummyWaveform(duration=2), parameter_names={'foo'})
         scope = DictScope.from_kwargs(foo=7)
         seq = SequencePulseTemplate(sub1, sub2, measurements=[('a', 0, 1)], parameter_constraints={'foo < 2'})
-        loop = Loop()
+        program_builder = default_program_builder()
         with self.assertRaises(ParameterConstraintViolation):
             seq._internal_create_program(scope=scope,
                                          measurement_mapping={'a': 'a', 'b': 'b'},
                                          channel_mapping=dict(),
                                          global_transformation=None,
                                          to_single_waveform=set(),
-
-                                         parent_loop=loop)
+                                         program_builder=program_builder)
+        self.assertIsNone(program_builder.to_program())
 
     def test_internal_create_program_parameter_missing(self) -> None:
         sub1 = DummyPulseTemplate(duration=3, waveform=DummyWaveform(duration=3), measurements=[('b', 1, 2)])
-        sub2 = DummyPulseTemplate(duration='d', waveform=DummyWaveform(duration=2), parameter_names={'foo'})
+        sub2 = DummyPulseTemplate(duration=2, waveform=DummyWaveform(duration=2), parameter_names={'foo'})
         seq = SequencePulseTemplate(sub1, sub2, measurements=[('a', 'bar', 1)], parameter_constraints={'foo < 2'})
-        loop = Loop()
 
         # test parameter from constraints
         scope = DictScope.from_kwargs()
+        program_builder = default_program_builder()
         with self.assertRaises(ParameterNotProvidedException):
             seq._internal_create_program(scope=scope,
                                          measurement_mapping={'a': 'a', 'b': 'b'},
                                          channel_mapping=dict(),
                                          global_transformation=None,
                                          to_single_waveform=set(),
-                                         parent_loop=loop)
+                                         program_builder=program_builder)
+        self.assertIsNone(program_builder.to_program())
 
         # test parameter from measurements
         scope = DictScope.from_mapping({'foo': 1})
+        program_builder = default_program_builder()
         with self.assertRaises(ParameterNotProvidedException):
             seq._internal_create_program(scope=scope,
                                          measurement_mapping={'a': 'a', 'b': 'b'},
                                          channel_mapping=dict(),
                                          global_transformation=None,
                                          to_single_waveform=set(),
-                                         parent_loop=loop)
-
-        # test parameter from duration
-        scope = DictScope.from_mapping({'foo': 1, 'bar': 0})
-        with self.assertRaises(ParameterNotProvidedException):
-            seq._internal_create_program(scope=scope,
-                                         measurement_mapping={'a': 'a', 'b': 'b'},
-                                         channel_mapping=dict(),
-                                         global_transformation=None,
-                                         to_single_waveform=set(),
-                                         parent_loop=loop)
+                                         program_builder=program_builder)
+        self.assertIsNone(program_builder.to_program())
 
 
 class SequencePulseTemplateTestProperties(SequencePulseTemplateTest):
diff --git a/tests/pulses/sequencing_dummies.py b/tests/pulses/sequencing_dummies.py
index 889aea575..21c3c7e62 100644
--- a/tests/pulses/sequencing_dummies.py
+++ b/tests/pulses/sequencing_dummies.py
@@ -1,17 +1,21 @@
 """STANDARD LIBRARY IMPORTS"""
 import numbers
+import typing
 from typing import Tuple, List, Dict, Optional, Set, Any, Union, Mapping
 import copy
 
 import numpy
 import unittest
 
+import qupulse.program.loop
+
 """LOCAL IMPORTS"""
 from qupulse.parameter_scope import Scope
-from qupulse._program._loop import Loop
+from qupulse.program.loop import Loop
 from qupulse.utils.types import MeasurementWindow, ChannelID, TimeType, time_from_float
 from qupulse.serialization import Serializer
 from qupulse._program.waveforms import Waveform
+from qupulse.program import ProgramBuilder
 from qupulse.pulses.pulse_template import AtomicPulseTemplate
 from qupulse.pulses.interpolation import InterpolationStrategy
 from qupulse.expressions import Expression, ExpressionScalar
@@ -20,10 +24,13 @@
 class MeasurementWindowTestCase(unittest.TestCase):
 
     def assert_measurement_windows_equal(self, expected, actual) -> bool:
-        self.assertEqual(expected.keys(), actual.keys())
-        for k in expected:
-            self.assertEqual(list(expected[k][0]), list(actual[k][0]))
-            self.assertEqual(list(expected[k][1]), list(actual[k][1]))
+        def normalize_measurement_windows(mw):
+            return {name: ([bs[idx] for idx in numpy.argsort(bs)], [ls[idx] for idx in numpy.argsort(bs)])
+                    for name, (bs, ls) in mw.items()}
+
+        expected = normalize_measurement_windows(expected)
+        actual = normalize_measurement_windows(actual)
+        self.assertEqual(expected, actual)
 
 
 class DummyWaveform(Waveform):
@@ -47,7 +54,8 @@ def compare_key(self) -> Any:
             except AttributeError:
                 pass
             return hash(
-                tuple(sorted((channel, output.tobytes()) for channel, output in self.sample_output.items()))
+                tuple(sorted((channel, getattr(output, 'tobytes', lambda: output)())
+                             for channel, output in self.sample_output.items()))
             )
         else:
             return id(self)
@@ -198,15 +206,18 @@ def _internal_create_program(self, *,
                                  channel_mapping: Dict[ChannelID, Optional[ChannelID]],
                                  global_transformation: Optional['Transformation'],
                                  to_single_waveform: Set[Union[str, 'PulseTemplate']],
-                                 parent_loop: Loop) -> None:
+                                 program_builder: ProgramBuilder) -> None:
         measurements = self.get_measurement_windows(scope, measurement_mapping)
-        self.create_program_calls.append((scope, measurement_mapping, channel_mapping, parent_loop))
+        self.create_program_calls.append((scope, measurement_mapping, channel_mapping, program_builder))
         if self._program:
+            program_builder = typing.cast(program_builder, qupulse.program.loop.LoopBuilder)
+            parent_loop = program_builder._top
+
             parent_loop.add_measurements(measurements)
             parent_loop.append_child(waveform=self._program.waveform, children=self._program.children)
         elif self.waveform:
-            parent_loop.add_measurements(measurements)
-            parent_loop.append_child(waveform=self.waveform)
+            program_builder.measure(measurements)
+            program_builder.play_arbitrary_waveform(waveform=self.waveform)
 
     def build_waveform(self,
                        parameters: Dict[str, numbers.Real],
diff --git a/tests/pulses/table_pulse_template_tests.py b/tests/pulses/table_pulse_template_tests.py
index 40414ccd5..46cf99ae8 100644
--- a/tests/pulses/table_pulse_template_tests.py
+++ b/tests/pulses/table_pulse_template_tests.py
@@ -219,15 +219,22 @@ def test_external_constraints(self):
         table.build_waveform(parameters=dict(v=1., w=2, t=0.1, x=1.2, y=1, h=2),
                              channel_mapping={0: 0, 1: 1})
 
-    def test_get_entries_instantiated_one_entry_float_float(self) -> None:
+    def test_get_entries_instantiated_empty(self):
         table = TablePulseTemplate({0: [(0, 2)]})
+        self.assertEqual({}, table.get_entries_instantiated(dict()))
+
+    def test_get_entries_instantiated_one_entry_float_float(self) -> None:
+        table = TablePulseTemplate({0: [(1, 2)]})
         instantiated_entries = table.get_entries_instantiated(dict())[0]
-        self.assertEqual([(0, 2, HoldInterpolationStrategy())], instantiated_entries)
+        self.assertEqual([(0, 2, HoldInterpolationStrategy()), (1, 2, HoldInterpolationStrategy())],
+                         instantiated_entries)
 
     def test_get_entries_instantiated_one_entry_float_declaration(self) -> None:
-        table = TablePulseTemplate({0: [(0, 'foo')]})
+        table = TablePulseTemplate({0: [(1, 'foo')]})
         instantiated_entries = table.get_entries_instantiated({'foo': 2})[0]
-        self.assertEqual([(0, 2, HoldInterpolationStrategy())], instantiated_entries)
+        self.assertEqual([(0, 2, HoldInterpolationStrategy()),
+                          (1, 2, HoldInterpolationStrategy())],
+                         instantiated_entries)
 
     def test_get_entries_instantiated_two_entries_float_float_declaration_float(self) -> None:
         table = TablePulseTemplate({0: [('foo', -2.)]})
diff --git a/tests/qctoolkit_alias_tests.py b/tests/qctoolkit_alias_tests.py
index 5c7a5d834..1ae446776 100644
--- a/tests/qctoolkit_alias_tests.py
+++ b/tests/qctoolkit_alias_tests.py
@@ -10,7 +10,7 @@ def test_alias(self):
         self.assertIs(qctoolkit.pulses.TablePT, qupulse.pulses.TablePT)
 
     def test_class_identity(self):
-        from qupulse._program._loop import Loop as Loop_qu
-        from qctoolkit._program._loop import Loop as Loop_qc
+        from qupulse.program.loop import Loop as Loop_qu
+        from qctoolkit.program.loop import Loop as Loop_qc
 
         self.assertIs(Loop_qc, Loop_qu)
diff --git a/tests/serialization_tests.py b/tests/serialization_tests.py
index f230bd6da..1ebd93b8e 100644
--- a/tests/serialization_tests.py
+++ b/tests/serialization_tests.py
@@ -1051,9 +1051,12 @@ def test_deserialize_storage_is_not_default_registry_id_occupied(self) -> None:
         del pulse_storage
 
         pulse_storage = PulseStorage(backend)
-        with self.assertRaisesRegex(RuntimeError, "Pulse with name already exists"):
+        with self.assertRaises(ValueError) as cm:
             pulse_storage['peter']
 
+        # this is shitty
+        self.assertIsInstance(cm.exception.__cause__, RuntimeError)
+
     def test_deserialize_twice_same_object_storage_is_default_registry(self) -> None:
         backend = DummyStorageBackend()
 
diff --git a/tests/utils/numeric_tests.py b/tests/utils/numeric_tests.py
index b632f058b..38410c682 100644
--- a/tests/utils/numeric_tests.py
+++ b/tests/utils/numeric_tests.py
@@ -5,7 +5,7 @@
 from collections import deque
 from itertools import islice
 
-from qupulse.utils.numeric import approximate_rational, approximate_double, smallest_factor_ge
+from qupulse.utils.numeric import approximate_rational, approximate_double, smallest_factor_ge, lcm
 
 
 def stern_brocot_sequence() -> Iterator[int]:
@@ -120,3 +120,14 @@ def test_smallest_factor_ge(self):
             self.assertEqual(smallest_factor_ge(45, 4), 5, brute_force)
             self.assertEqual(smallest_factor_ge(45, 5), 5, brute_force)
             self.assertEqual(smallest_factor_ge(36, 8), 9, brute_force)
+
+
+class LeastCommonMultipleTests(unittest.TestCase):
+    def test_few_args(self):
+        self.assertEqual(1, lcm())
+        self.assertEqual(5, lcm(5))
+
+    def test_multi_args(self):
+        self.assertEqual(15, lcm(3, 5))
+        self.assertEqual(0, lcm(3, 0))
+        self.assertEqual(20, lcm(2, 5, 4, 10))
diff --git a/tests/utils/performance_tests.py b/tests/utils/performance_tests.py
index d158dce5c..bc31960ae 100644
--- a/tests/utils/performance_tests.py
+++ b/tests/utils/performance_tests.py
@@ -1,8 +1,12 @@
 import unittest
+import warnings
 
 import numpy as np
 
-from qupulse.utils.performance import _time_windows_to_samples_numba, _time_windows_to_samples_numpy
+from qupulse.utils.performance import (
+    _time_windows_to_samples_numba, _time_windows_to_samples_numpy,
+    _average_windows_numba, _average_windows_numpy, average_windows,
+    shrink_overlapping_windows, WindowOverlapWarning)
 
 
 class TimeWindowsToSamplesTest(unittest.TestCase):
@@ -28,3 +32,79 @@ def test_unsorted(self):
             self.assert_implementations_equal(begins, lengths, sr)
 
 
+class WindowAverageTest(unittest.TestCase):
+    @staticmethod
+    def assert_implementations_equal(time, values, begins, ends):
+        numpy_result = _average_windows_numpy(time, values, begins, ends)
+        numba_result = _average_windows_numba(time, values, begins, ends)
+        np.testing.assert_allclose(numpy_result, numba_result)
+
+    def setUp(self):
+        self.begins = np.array([1., 2., 3.] + [4.] + [6., 7., 8., 9., 10.])
+        self.ends = self.begins + np.array([1., 1., 1.] + [3.] + [2., 2., 2., 2., 2.])
+        self.time = np.arange(10).astype(float)
+        self.values = np.asarray([
+            np.sin(self.time),
+            np.cos(self.time),
+        ]).T
+
+    def test_dispatch(self):
+        _ = average_windows(self.time, self.values, self.begins, self.ends)
+        _ = average_windows(self.time, self.values[..., 0], self.begins, self.ends)
+
+    def test_single_channel(self):
+        self.assert_implementations_equal(self.time, self.values[..., 0], self.begins, self.ends)
+        self.assert_implementations_equal(self.time, self.values[..., :1], self.begins, self.ends)
+
+    def test_dual_channel(self):
+        self.assert_implementations_equal(self.time, self.values, self.begins, self.ends)
+
+
+class TestOverlappingWindowReduction(unittest.TestCase):
+    def setUp(self):
+        self.shrank = np.array([1, 4, 8], dtype=np.uint64), np.array([3, 4, 4], dtype=np.uint64)
+        self.to_shrink = np.array([1, 4, 7], dtype=np.uint64), np.array([3, 4, 5], dtype=np.uint64)
+
+    def assert_noop(self, shrink_fn):
+        begins = np.array([1, 3, 5], dtype=np.uint64)
+        lengths = np.array([2, 1, 6], dtype=np.uint64)
+        result = shrink_fn(begins, lengths)
+        np.testing.assert_equal((begins, lengths), result)
+
+        begins = (np.arange(100) * 176.5).astype(dtype=np.uint64)
+        lengths = (np.ones(100) * 10 * np.pi).astype(dtype=np.uint64)
+        result = shrink_fn(begins, lengths)
+        np.testing.assert_equal((begins, lengths), result)
+
+        begins = np.arange(15, dtype=np.uint64)*16
+        lengths = 1+np.arange(15, dtype=np.uint64)
+        result = shrink_fn(begins, lengths)
+        np.testing.assert_equal((begins, lengths), result)
+
+    def assert_shrinks(self, shrink_fn):
+        with warnings.catch_warnings():
+            warnings.simplefilter("always", WindowOverlapWarning)
+            with self.assertWarns(WindowOverlapWarning):
+                shrank = shrink_fn(*self.to_shrink)
+        np.testing.assert_equal(self.shrank, shrank)
+
+    def assert_empty_window_error(self, shrink_fn):
+        invalid = np.array([1, 2], dtype=np.uint64), np.array([5, 1], dtype=np.uint64)
+        with self.assertRaisesRegex(ValueError, "Overlap is bigger than measurement window"):
+            shrink_fn(*invalid)
+
+    def test_shrink_overlapping_windows_numba(self):
+        def shrink_fn(begins, lengths):
+            return shrink_overlapping_windows(begins, lengths, use_numba=True)
+
+        self.assert_noop(shrink_fn)
+        self.assert_shrinks(shrink_fn)
+        self.assert_empty_window_error(shrink_fn)
+
+    def test_shrink_overlapping_windows_numpy(self):
+        def shrink_fn(begins, lengths):
+            return shrink_overlapping_windows(begins, lengths, use_numba=False)
+
+        self.assert_noop(shrink_fn)
+        self.assert_shrinks(shrink_fn)
+        self.assert_empty_window_error(shrink_fn)
diff --git a/tests/utils/sympy_tests.py b/tests/utils/sympy_tests.py
index 871ac2443..b1ff6b1e6 100644
--- a/tests/utils/sympy_tests.py
+++ b/tests/utils/sympy_tests.py
@@ -345,7 +345,11 @@ def evaluate(self, expression: Union[sympy.Expr, np.ndarray], parameters):
         if isinstance(expression, np.ndarray):
             return self.evaluate(sympy.Array(expression), parameters)
 
-        result, _ = evaluate_compiled(expression, parameters, compiled=None)
+        try:
+            result, _ = evaluate_compiled(expression, parameters, compiled=None)
+        except Exception as err:
+            raise AssertionError(f"Compiled evaluation of {expression!r} with {parameters!r} failed: {err!r}",
+                                 expression, parameters) from err
 
         if isinstance(result, (list, tuple)):
             return np.array(result)
diff --git a/tests/utils/utils_tests.py b/tests/utils/utils_tests.py
index 6ec75092c..83e1a26aa 100644
--- a/tests/utils/utils_tests.py
+++ b/tests/utils/utils_tests.py
@@ -2,7 +2,7 @@
 from unittest import mock
 from collections import OrderedDict
 
-from qupulse.utils import checked_int_cast, replace_multiple, _fallback_pairwise
+from qupulse.utils import checked_int_cast, replace_multiple, _fallback_pairwise, to_next_multiple
 
 
 class PairWiseTest(unittest.TestCase):
@@ -102,3 +102,42 @@ def test_replace_multiple_overlap(self):
         replacements = OrderedDict(reversed(replacement_list))
         result = replace_multiple('asdf', replacements)
         self.assertEqual(result, '2')
+
+
+class ToNextMultipleTests(unittest.TestCase):
+    def test_to_next_multiple(self):
+        from qupulse.utils.types import TimeType
+        from qupulse.expressions import ExpressionScalar
+        
+        duration = TimeType.from_float(47.1415926535)
+        evaluated = to_next_multiple(sample_rate=TimeType.from_float(2.4),quantum=16)(duration)
+        expected = ExpressionScalar('160/3')
+        self.assertEqual(evaluated, expected)
+        
+        duration = TimeType.from_float(3.1415926535)
+        evaluated = to_next_multiple(sample_rate=TimeType.from_float(2.4),quantum=16,min_quanta=13)(duration)
+        expected = ExpressionScalar('260/3')
+        self.assertEqual(evaluated, expected)
+        
+        duration = 6185240.0000001
+        evaluated = to_next_multiple(sample_rate=1.0,quantum=16,min_quanta=13)(duration)
+        expected = 6185248
+        self.assertEqual(evaluated, expected)
+        
+        duration = 0.
+        evaluated = to_next_multiple(sample_rate=1.0,quantum=16,min_quanta=13)(duration)
+        expected = 0.
+        self.assertEqual(evaluated, expected)
+        
+        duration = ExpressionScalar('abc')
+        evaluated = to_next_multiple(sample_rate=1.0,quantum=16,min_quanta=13)(duration).evaluate_in_scope(dict(abc=0.))
+        expected = 0.
+        self.assertEqual(evaluated, expected)
+        
+        duration = ExpressionScalar('q')
+        evaluated = to_next_multiple(sample_rate=ExpressionScalar('w'),quantum=16,min_quanta=1)(duration).evaluate_in_scope(
+                        dict(q=3.14159,w=1.0))
+        expected = 16.
+        self.assertEqual(evaluated, expected)
+        
+        
\ No newline at end of file