Building Your First Circuit

Info

This tutorial uses the low-level pybrid-syntax which is compatible with all supported devices; for LUCIDACs, there is the higher-level lucipy syntax especially good for beginners.

Before starting on a simple circuit, you will need to set up pybrid and to find out the IP address of your device. Throughout this document we are assuming a LUCIDAC reachable at 192.168.1.2. The circuit we are about to build models a harmonic oscillator, governed by the ODE \(\frac{d^2}{dt^2}h(t) = -h(t)\) with \(h(0) = 0.42\), and we will be capturing both \(h(t)\) and \(\dot{h}(t) = \frac{d}{dt}h\) via the analog-to-digital converters (ADCs) on the device. To understand why we configure the computer the way we do, please read the dedicated sections in the architecture guide.

Setting up the script

We start by importing the relevant classes from pybrid. The example uses the LUCIDAC controller, but the same code translates to an mREDAC by swapping the import for from pybrid.redac.controller import Controller:

import asyncio
from matplotlib import pyplot as plt
import numpy as np

from pybrid.lucidac.controller import Controller
from pybrid.redac import DAQConfig, RunConfig

pybrid uses Python's async machinery throughout to hide network latencies and I/O, so the rest of the example lives inside an async function:

async def main():
    ...

asyncio.run(main())

With that scaffolding in place, we are ready to configure the LUCIDAC. Before we touch any code, here are the two abstractions to keep in mind. A Controller is the client class for analog devices: it connects to hardware, builds the entity object model from what the device reports, and handles all communication with the device (see the protocol for details). The entity object model is the in-memory representation of those hardware components in pure Python; it is created at connect time through automatic deserialization of the device's hardware specification, and is then configured by the user to realize a circuit before being serialized by the controller and transmitted as the hardware configuration. In short: a specification describes what hardware the analog device has, and a configuration describes how its coefficients, switches and so on are set.

The most common usage pattern is the "configure-run-evaluate" loop. The controller connects to the device and downloads its specification, which is a hierarchical description of its hardware structure (see the hierarchy page). From that specification, pybrid generates the entity object model (the "computer"). The user then configures the computer in memory to realize a particular analog circuit. The computer's configuration is serialized into a protocol message and sent to the device, which runs auto-calibration and finally executes the circuit. While the circuit runs, the device streams the captured samples back over the network, and pybrid collects them into a consolidated list of channels in Python.

Connecting to the device

The first step in our example is to instantiate a controller, have it connect to the device and reach into the entity object model:

async with Controller() as controller:

    # connect to LUCIDAC
    await controller.add_device(
        "192.168.1.2",
        5732
    )

    # retrieve the entity object model for the LUCIDAC (i.e. "cluster")
    computer = controller.computer
    carrier = computer.carriers[0]
    cluster = carrier.clusters[0]

The REDAC family has a hierarchical structure (again, see the hierarchy page) with carriers (mREDACs) as the smallest individually operable units. The LUCIDAC is somewhat special: it is roughly equivalent to a single cluster rather than to a full carrier, so in pybrid we model it as a carrier with exactly one cluster, and we configure the device by configuring that cluster.

Configuring the cluster

With the cluster reference in hand we can start configuring its blocks. The order matters: we work outwards from the M-block, then route signals through the U-block, weight them with the C-block, and finally close the loop with the I-block. Use your IDE or IntelliSense to explore the cluster object and its members as you go.

M-block: integrators and initial conditions

As shown in its architecture, each cluster has two slots for M-blocks (M0 and M1). While they are exchangeable, the M0 slot in most cases contains an MInt block with eight integrators, where each integrator \(i\) is wired to input \(i\) and output \(i\). Our ODE has \(\ddot{h}\) as its highest-order derivative, so we need two integrators: integrator 0 will integrate \(\dot{h}(t)\) to \(h(t)\), and integrator 1 will integrate \(\ddot{h}(t)\) to \(\dot{h}(t)\). Integrators have two properties we can set. The first, k_0, is the acceleration factor (default 10,000), which makes the system run 10,000 times faster than real time. The second, ic, is the initial condition. In this example we leave \(k_0\) at its default and only set the initial condition:

cluster.m0block.elements[0].ic = 0.42
cluster.m0block.elements[1].ic = 0  # 0 by default

U-block: fan-out routing

In the C-block, almost all lanes are interchangeable (with the exception of analog I/O), so we are free to choose which integrator output sits on which lane. For simplicity we put integrator 0's output on lane 0 and integrator 1's output on lane 1:

cluster.ublock.outputs[0] = 0
cluster.ublock.outputs[1] = 1

The U-block works by fan-out: for every output of the M-blocks, you can route the signal to multiple lanes in the C-block. The U-block also contains a constant giver, capable of producing the constants \(\{0.1, 1\}\), but we do not need it in this example.

C-block: coefficients

The C-block has one reconfigurable coefficient per lane, with each coefficient living in the range \([-1.0, 1.0]\). Together with the I-block's upscaling feature (described below), the effective range becomes \([-8.0, 8.0]\). According to our circuit diagram we need to weigh integrator 0's signal (lane 0) with \(-1.0\) and integrator 1's signal (lane 1) with \(1.0\):

cluster.cblock.elements[0].computation.factor = -1.0
cluster.cblock.elements[1].computation.factor = 1.0

I-block: closing the loop

All that is left now is to close the loop by feeding the signal on lane 0 back into integrator 1 and the signal on lane 1 back into integrator 0. The I-block works by implicit summation in a fan-in mode: for each output of the I-block (which is an input of the M-block), we assign a set of C-block lanes to be summed. The I-block can also upscale signals on individual lanes by a factor of 8, but we do not need that here:

cluster.iblock.outputs[0] = {1}
cluster.iblock.outputs[1] = {0}

Capturing the integrator outputs

The ADCs on LUCIDAC and REDAC devices are wired to M-block outputs. To capture the two integrator outputs we are interested in, we use the convenience function on the computer's DAQ object:

computer.daq.capture(
    cluster.m0block.elements[0],
    cluster.m0block.elements[1])

At this point the entity object model fully describes our ODE as a circuit. What is left is to configure the execution of that circuit and hand it to the device.

Running the circuit

We control execution through a Session object, which we obtain from the controller so that it is wired up correctly (it carries a reference to the controller, among other things):

session = controller.create_session()

All commands issued on a session use deferred execution: they are queued in the order they are added and only run when we finally call execute(). Before that, we configure how long the device should integrate and at what rate it should sample. The mode in which the analog computer actively integrates is called "OP", and the OP duration is set ahead of time with nanosecond precision:

run_config = RunConfig(op_time=2_560_000)

We also need to pick a sample rate, in samples per second. Note that the LUCIDAC's HybridController caps the total sampling rate at roughly 500 kHz divided across all captured signals. Since we are capturing two signals, we have around 250,000 samples per channel per second to play with; for this example we use 100,000:

daq_config = DAQConfig(sample_rate=100_000)

Thanks to the chaining syntax, a typical session can be assembled and fired off in a single sequence:

runs = await (
        session
        .set_config(computer)  # serialize and send configuration
        .calibrate(gain=True, offset=True)
        .run(run_config, daq_config)
        .execute()  # execute all of the former commands
    )

Multiple run() commands inside the same session would result in multiple entries in the runs list. Since we only have one run, we pick the first entry with run = runs[0].

Plotting the results

To round things off we use matplotlib to plot the captured data:

for channel in run.data:
    if channel is not None:
        plt.plot(np.array(channel).flatten())
plt.ylabel("Amplitude x")
plt.xlabel("'Time' t")
plt.show()

That's it, you just ran your first circuit on your device. Note that the approach we are taking here, defining block routing by hand, is much like assembly programming on digital systems. In most cases users can use anabrid's redacc compiler to automate the transformation from ODE to circuit.

Full example

The complete script is available for download: first_circuit.py.

import asyncio

import numpy as np
from matplotlib import pyplot as plt

from pybrid.lucidac.controller import Controller
from pybrid.redac import DAQConfig, RunConfig


async def main():

    async with Controller() as controller:
        # connect to LUCIDAC
        await controller.add_device("192.168.150.17", 5732)

        # retrieve the entity object model for the LUCIDAC (i.e. "cluster")
        computer = controller.computer
        carrier = computer.carriers[0]
        cluster = carrier.clusters[0]

        ###
        # CONFIGURATION
        ###

        # set initial conditions for integrators (integrators 0, 1)
        cluster.m0block.elements[0].ic = 0.42
        cluster.m0block.elements[1].ic = 0  # 0 by default

        # connnect integrators through U, C, I blocks
        cluster.ublock.outputs[0] = 0
        cluster.cblock.elements[0].computation.factor = -1.0
        cluster.iblock.outputs[1] = [0]

        cluster.ublock.outputs[1] = 1
        cluster.cblock.elements[1].computation.factor = 1.0
        cluster.iblock.outputs[0] = [1]

        # ALTERNATIVE: connect via convenient router() functionality
        # cluster.route(0, 0, -1.0, 1)
        # cluster.route(1, 1, 1.0, 0)

        # capture of both integrators' output signals
        computer.daq.capture(cluster.m0block.elements[0], cluster.m0block.elements[1])

        ###
        # EXECUTION
        ###

        # determine how many samples are drawn per channel and how long we
        # are integrating (= OP time, in ns)
        run_config = RunConfig(op_time=2_560_000)
        daq_config = DAQConfig(sample_rate=100_000)

        # create a session (used to concatenate multiple commands and let the
        # runtime handle their processing
        session = controller.create_session()
        runs = await (
            session.set_config(computer)  # seriazlize and send configuration
            .calibrate(gain=True, offset=True)
            .run(run_config, daq_config)
            .execute()  # execute all of the former commands
        )

        # only one circuit executed - pick first run object; run.data then
        # contains one list of samples per captured element
        run = runs[0]

        # Plot data.
        for channel in run.data:
            if channel is not None:
                plt.plot(np.array(channel).flatten())
        plt.ylabel("Amplitude x")
        plt.xlabel("'Time' t")
        plt.show()


asyncio.run(main())