Data Visualization and Recording

Often, you'll want access to historical data generated by Devices in addition to seeing data about their current states. Most Devices in the Aqueduct Library have the ability to record their state at each update interval. These recorded timeseries can be visualized in the user interface with a Chart Widget.

Let's add a new Tab and Tab Container to our window and then add a Chart Widget:

A Chart Widget allows you to view Recordable timeseries for both Device and User data. We'll see how to create a User Recordable later, but for now let's add a Device Recordable to the Chart by selecting:

Add
> Series > Devices > peristaltic_pump_000001 > pump 0 disp. (mL)

Use the controls section in the left menu to start peristaltic_pump_000001 in the clockwise direction at 10 mL/min. Make sure that the checkbox in the record toggle is selected and then press the start button on the control panel:

As the pump starts operating, you'll see a line appear on the chart that increases with time. We're plotting the milliliters displaced by the pump.

Enter a larger rate value (20 mL/min, for instance) in the controls panel and press the start button again. The milliliters displaced value will reset to 0 and then start increasing at a higher rate.

You can add multiple Chart Widgets to your window layout. Add a second Chart Widget to the Tab Container and then add the pump 0 rate (mL/min) timeseries.

Let's revisit our infinite loop recipe from the last step and modify it to record the changes in the pump's rate. The only change that we need to make is the addition of setting the record argument to True in the pump's start method:

pump.start(commands, record = True)

You'll see a record argument in many Device methods throughout the Aqueduct API. The functionality of this argument is the same: setting record to True will instruct the platform to record the device's state in a timeseries and make it accessible in Chart Widgets.

Make the above change to your code in the Editor Widget, queue the Recipe, and begin execution. Now, in addition to seeing the pump cycling between 0 and 50 mL/min, you'll see a sawtooth-like timeseries of the pump's rpm as our Recipe continuously changes its speed.

You can create your own timeseries using the Aqueduct API's Recordable class. For instance, you may wish to calculate a derived value using device data as an input.

Let's make a Recordable to display the total volume displaced by the pump during our Recipe. Go back to the PeristalticPump class in the Aqueduct API (peristaltic.py) and take a look at the get_ml_done method:

aqueduct/devices/pump/peristaltic.py

class PeristalticPump(devices.base.obj.Device):

    # snip

    def get_ml_done(self) -> Tuple[float]:
        """
        Get the volume dispensed by the peristaltic pump in milliliters.

        :return: Volume dispensed in milliliters.
        :rtype: tuple
        """
        return self.extract_live_as_tuple(PeristalticPumpLiveKeys.ml_done.value)

    ## snip

We can use the pump's get_ml_done method to calculate volume displaced in milliliters.

The other tool we'll need to implement our volume counter is the Recordable API, which is found here (Recordable) and here (Aqueduct recordable method):

The API methods are reproduced below for easier reference:

aqueduct/core/aq.py

class Aqueduct(object):

  # snip

  def recordable(
        self,
        name: str,
        value: typing.Union[float, int, bool, str, datetime.datetime, list],
        dtype: str = None,
    ) -> Recordable:
        """
        Creates a new Recordable object and registers it with Aqueduct.

        :param name: The name of the Recordable.
        :type name: str
        :param value: The initial value of the Recordable.
        :type value: Union[float, int, bool, str, datetime.datetime, list]
        :param dtype: The data type of the Recordable value, defaults to None.
        :type dtype: str, optional
        :return: The newly created Recordable object.
        :rtype: Recordable
        """
        recordable = Recordable(name, value, dtype)
        self.register_recordable(recordable)
        self.update_recordable(recordable)
        return recordable

  # snip

aqueduct/core/recordable.py

class Recordable:
    """
    The `Recordable` class allows you to log timestamped data.

    :param name: Name of the recordable, will be displayed on the UI, should be unique
    :type name: str, required
    :param value: Initial value to be assigned to the recordable on creation
    :type value: float, int, bool, str, datetime.datetime, list, required
    :param dtype: Specify the type of value, used to ensure that users cannot
        enter an invalid value
    :type dtype: {'int', 'float', 'bool', 'list', 'datetime', 'str'}, optional
    """

    name: str = None
    value: Union[float, int, bool, str, datetime.datetime, list] = None
    dtype: str = None
    timestamp = None

    _aq: "Aqueduct" = None

    def __init__(
        self,
        name: str,
        value: Union[float, int, bool, str, datetime.datetime, list],
        dtype: str = None,
    ):
        """
        Constructor method.

        :param name: name of the Recordable, will be displayed on the UI, should be unique
        :type name: str, required
        :param value: value to be assigned to the Recordable on creation
        :type value: float, int, bool, str, datetime.datetime, list, required
        :param dtype: specify the type of value, used to ensure that users cannot enter an invalid value
        :type dtype: {'int', 'float', 'bool', 'list', 'datetime', 'str'}, optional
        """
        if dtype is None:
            try:
                dtype = type(value).__name__
                if dtype not in ALLOWED_DTYPES:
                    raise ValueError(
                        "Object of type {} is not allowed as a Recordable".format(
                            {dtype}
                        )
                    )
            except Exception:
                raise ValueError("Invalid Aqueduct Recordable")

        self.name = name
        self.value = value
        self.dtype = dtype

    # not all methods present

    def assign(self, aqueduct: "Aqueduct"):
        """
        Assigns the Recordable to an `Aqueduct` instance.

        :param aqueduct: The `Aqueduct` instance to assign the Recordable to.
        :type aqueduct: Aqueduct, required
        """
        self._aq = aqueduct

    def update(self, value):
        """
        Updates the value of the Recordable.

        :param value: The new value for the Recordable.
        :type value: Union[float, int, bool, str, datetime.datetime, list], required
        """
        self.value = value
        self._aq.update_recordable(self)

    def clear(self):
        """
        Clears the value of the Recordable.
        """
        self._aq.clear_recordable(self)

So, our strategy will be to create a Recordable instance using the aqueduct object's recordable method, assign it a name of our choosing, and then update it as needed with the value returned from the pump's ml_pumped method.

Let's modify our code to create the Recordable before the entry to our infinite loop and update the value at the end of each loop iteration:

from aqueduct.core.aq import Aqueduct
from aqueduct.core.aq import InitParams
from aqueduct.devices.pump import PeristalticPump

# parse initialization parameters and create Aqueduct instance
params = InitParams.parse()
aq = Aqueduct(params.user_id, params.ip_address, params.port)

# initialize the devices and set a command delay
aq.initialize(params.init)
aq.set_command_delay(0.05)

# get the peristaltic pump device and create a command object
pump: PeristalticPump = aq.devices.get("peristaltic_pump_000001")

volume_pumped = aq.recordable(        ## <--- added recordable creation
  name="ml_pumped",                   ## <--- name will be displayed on chart Widget
  dtype=float.__name__,               ## <--- define the data type
  value=0.,                           ## <--- set the initial value
)

commands = pump.make_commands()

command = pump.make_start_command(
    mode=pump.MODE.Continuous,
    rate_units=pump.RATE_UNITS.MlMin,
    rate_value=2,
    direction=pump.STATUS.Clockwise,
)

# set the command for the first (and only) node of the pump
pump.set_command(commands, 0, command)

# now send the start command
pump.start(commands)

# set the maximum speed and speed increment for the pump
MAX_SPEED: float = 50
INCREMENT: float = 1

# calculate the number of steps based on the maximum speed and increment
STEPS = int(MAX_SPEED / INCREMENT)

while True:
    # ramp up the speed to `top_speed_rpm`
    for i in range(0, STEPS):
        commands = pump.make_commands()

        # create a command to change the pump speed
        c = pump.make_change_speed_command(
            rate_value=i * INCREMENT, rate_units=pump.RATE_UNITS.MlMin
        )

        pump.set_command(commands, 0, c)

        pump.change_speed(commands)

        volume_pumped.update(pump.get_ml_done()[0]) ## <--- update the value

        print(f"Ramping up to: {i * INCREMENT} mL/min")

    # ramp down the speed to `2 mL/min`
    for i in range(STEPS, 0, -1):
        commands = pump.make_commands()

        # create a command to change the pump speed
        c = pump.make_change_speed_command(
            rate_value=i * INCREMENT, rate_units=pump.RATE_UNITS.MlMin
        )

        pump.set_command(commands, 0, c)

        pump.change_speed(commands)

        volume_pumped.update(pump.get_ml_done()[0]) ## <--- update the value

        print(f"Ramping down to: {i * INCREMENT} mL/min")

pump.stop()
print("Complete!")

Let's Queue and rerun the script. After starting, navigate to the tab with your Chart Widgets and add the ml_pumped series by selecting:

Add
> Series > User > ml_pumped

from the Chart context menu. You should see a second timeseries with a line that continues to increase as the pump traverses more revolutions.

Looks good!

This Chapter has introduced us to Recordables and recording data, which make timeseries data available to plot in a Chart Widget. Many device API methods accept a record argument that will instruct the application to record that device's state at each update interval. User Recordables can be used to extend the built-in device recording parameters with derived or calculated values that you may wish to record over time.

Now, let's move on to the next Chapter, which will introduce Setpoints and Logging.