Getting Started with Atomiq

This guide is the best way to get you started with the realtime part of the Atomiq One software stack. If you want to start with the non-realtime part (cameras, lasers, monitoring, …), jump to Getting Started with HEROS first.

Atomiq is a convenience layer above the ARTIQ real-time experiment control system. It mainly provides an abstraction layer that allows you to move hardware-specific information (like what device is plugged into what) into a configuration and to work with generic software objects that represent the actual hardware in the lab (like AOMs, coils, lasers, etc.). You can find the full Atomiq () documentation online.

Atomiq is based on three core concepts:

  • Components – Put the hardware information of your setup into a configuration file. Work with high-level objects like AOM, Laser, or Coil instead of raw Sinara devices. Set quantities in physically meaningful units and attach calibrations.

  • Blocks – Reusable, recursive sub-experiments. Write a sequence (e.g. MOT loading, imaging) once and reuse it in all your experiments.

  • Chunking – Group multiple experiment runs into a single ARTIQ kernel to reduce the overhead of kernel compilation and loading, enabling fast repetition rates above 1 Hz.

On top of that, Atomiq is natively and tightly integrated into HEROS (). You can use any HERO (network-transparent hardware object) directly as a component in your experiment by prefixing its name with $. The Atomiq scheduler and experiment database are themselves HEROs, so you can schedule experiments, monitor their progress, and stream data out of running experiments.

A basic understanding of a few tools and concepts is assumed, so we recommend familiarising yourself with the following topics:

Tool / Concept

Description

Docker

A container ecosystem to run software consistently on different environments.

uv

A modern Python virtual environment manager.

CLI

Basic usage of a command line interface like bash.

ARTIQ

The real-time experiment control system that Atomiq builds upon. Basic familiarity with ARTIQ concepts (device database, experiments, kernels) is helpful but not strictly required.

Tip

Also check out our “Atomiq Deep Dive” webinar!

Setting up Atomiq

Atomiq provides Docker images that bundle everything needed to run Atomiq inside the ARTIQ ecosystem. This is the easiest way to get Atomiq and ARTIQ running in a few simple steps.

Note

We do not support nor recommend running the Atomiq master on Windows. The HEROS connections depend strongly on the Windows version, the hypervisor used, and probably the weather.

Create a new folder for your Atomiq project. In this tutorial we call it my_atomiq_lab:

mkdir my_atomiq_lab
cd my_atomiq_lab

Inside this folder, create a docker-compose.yml file:

docker-compose.yml
services:
  artiq-master:
    container_name: "artiq-master"
    image: registry.gitlab.com/atomiq-project/atomiq:stable
    restart: unless-stopped
    network_mode: "host"
    volumes:
      - ".:/experiment" # this should point to the folder where the device_db and the repository is located
      # - ".:/git/atomiq" # use the local atomiq installation
      # - "/path/to/some_package:/git/custom/some_package" # include custom python code

    # master command (see atomiq_master doc for more options)
    command: ["python", "-m", "atomiq.frontend.atomiq_master", "--bind", "*", "-r", "/experiment/repository", "--device-db", "/experiment/device_db.py", "--name", "my_master", "--heros-realm", "my_realm"]
  artiq-ctlmgr:
    container_name: "artiq-ctlmgr"
    image: registry.gitlab.com/atomiq-project/atomiq:stable
    restart: unless-stopped
    network_mode: "host"
    depends_on:
      artiq-master:
        condition: service_healthy
    command: ["artiq_ctlmgr", "--bind", "*"]

Important

The network_mode: "host" is required for proper HEROS discovery. HEROS uses multicast for service discovery, which does not work with Docker’s default bridge networking. If you cannot use host networking, you can alternatively specify --heros-connect tcp/<IP_OR_HOSTNAME>:7447 in the master command and run a zenoh router as a peer.

Note

The --name flag sets a custom name for this Atomiq master instance. The --heros-realm flag (defaults to heros) sets the HEROS realm of all Atomiq-related HEROs. In this tutorial we use the realm my_realm.

Tip

In this host-network configuration, the host arguments of core_log, core_moninj, and core_analyzer in your device_db.py must be set to "localhost".

Start the services:

docker compose up -d

You now have a fully featured ARTIQ-compatible master running, with all Atomiq extensions enabled.

Tip

Learn more about all installation options (Docker run, macvlan, custom code) in the installation docs.

Setting up your first Atomiq project

An Atomiq project consists of the following parts:

my_atomiq_lab/
├── docker-compose.yml      # starts the atomiq master and ctlmgr
├── device_db.py            # ARTIQ device database + atomiq components
├── components.py           # atomiq component definitions
└── repository/             # your experiment scripts
    └── my_experiment.py

The device_db.py is the standard ARTIQ device database with one addition: the components key holds the Atomiq component definitions.

Create a minimal device_db.py for your setup. Below is an example for a Kasli system with a Urukul DDS, a Sampler ADC, and some TTL outputs. Adjust the core_addr to the IP address of your Kasli:

Device Database
device_db.py
from atomiq.helper import components_import_helper

components = components_import_helper("components", "components")
core_addr = "192.168.1.100"  # <-- adjust to your Kasli IP
ctl_mgr = "localhost" # <-- this may need adjustment if you start
                      #     the docker container with other networking

device_db = {
    "components": components,
    "core": {
        "type": "local",
        "module": "artiq.coredevice.core",
        "class": "Core",
        "arguments": {
            "host": core_addr,
            "ref_period": 1e-09,
            "analyzer_proxy": "core_analyzer",
            "target": "cortexa9",
            "satellite_cpu_targets": {}
        },
    },
    "core_log": {
        "type": "controller",
        "host": ctl_mgr,
        "port": 1068,
        "command": "aqctl_corelog -p {port} --bind {bind} " + core_addr
    },
    "core_moninj": {
        "type": "controller",
        "host": ctl_mgr,
        "port_proxy": 1383,
        "port": 1384,
        "command": "aqctl_moninj_proxy --port-proxy {port_proxy} --port-control {port} --bind {bind} " + core_addr
    },
    "core_analyzer": {
        "type": "controller",
        "host": ctl_mgr,
        "port_proxy": 1385,
        "port": 1386,
        "command": "aqctl_coreanalyzer_proxy --port-proxy {port_proxy} --port-control {port} --bind {bind} " + core_addr
    },
    "core_cache": {
        "type": "local",
        "module": "artiq.coredevice.cache",
        "class": "CoreCache"
    },
    "core_dma": {
        "type": "local",
        "module": "artiq.coredevice.dma",
        "class": "CoreDMA"
    },
}

# --- TTL outputs ---
device_db["ttl4"] = {
    "type": "local",
    "module": "artiq.coredevice.ttl",
    "class": "TTLOut",
    "arguments": {"channel": 0x000004},
}
device_db["ttl5"] = {
    "type": "local",
    "module": "artiq.coredevice.ttl",
    "class": "TTLOut",
    "arguments": {"channel": 0x000005},
}

# --- Urukul DDS ---
device_db["spi_urukul0"] = {
    "type": "local",
    "module": "artiq.coredevice.spi2",
    "class": "SPIMaster",
    "arguments": {"channel": 0x000008}
}
device_db["ttl_urukul0_io_update"] = {
    "type": "local",
    "module": "artiq.coredevice.ttl",
    "class": "TTLOut",
    "arguments": {"channel": 0x000009}
}
device_db["ttl_urukul0_sw0"] = {
    "type": "local",
    "module": "artiq.coredevice.ttl",
    "class": "TTLOut",
    "arguments": {"channel": 0x00000a}
}
device_db["ttl_urukul0_sw1"] = {
    "type": "local",
    "module": "artiq.coredevice.ttl",
    "class": "TTLOut",
    "arguments": {"channel": 0x00000b}
}
device_db["urukul0_cpld"] = {
    "type": "local",
    "module": "artiq.coredevice.urukul",
    "class": "CPLD",
    "arguments": {
        "spi_device": "spi_urukul0",
        "sync_device": None,
        "io_update_device": "ttl_urukul0_io_update",
        "refclk": 125000000.0,
        "clk_sel": 2,
        "clk_div": 0,
        "proto_rev": 8
    }
}
device_db["urukul0_ch0"] = {
    "type": "local",
    "module": "artiq.coredevice.ad9910",
    "class": "AD9910",
    "arguments": {
        "pll_en": 1,
        "chip_select": 4,
        "cpld_device": "urukul0_cpld",
        "sw_device": "ttl_urukul0_sw0"
    }
}
device_db["urukul0_ch1"] = {
    "type": "local",
    "module": "artiq.coredevice.ad9910",
    "class": "AD9910",
    "arguments": {
        "pll_en": 1,
        "chip_select": 5,
        "cpld_device": "urukul0_cpld",
        "sw_device": "ttl_urukul0_sw1"
    }
}

# --- Sampler ADC ---
device_db["spi_sampler0"] = {
    "type": "local",
    "module": "artiq.coredevice.spi2",
    "class": "SPIMaster",
    "arguments": {"channel": 0x000010}
}
device_db["sampler0"] = {
    "type": "local",
    "module": "artiq.coredevice.sampler",
    "class": "Sampler",
    "arguments": {"spi_device": "spi_sampler0"}
}

Tip

The components_import_helper function makes sure that changes to your components.py file are properly detected when the ARTIQ master rescans the device database at runtime. Always use it when importing components from a separate file.

Note

Changes to the device db (and thus the Atomiq components) only become available after the ARTIQ master rescanned the device_db.py. This can be triggered by running artiq_client -s <ARTIQ_MASTER_HOST> scan-devices or through the corresponding option when right-clicking the “explorer” view in the ARTIQ dashboard.

Defining your components

The Atomiq components are a mechanism to put the complexity of your experimental setup into a configuration and to work with the setup at a higher abstraction level. The key ideas are:

  • Have abstract, generic classes to represent different types of lab equipment (e.g. RFSource, DAC, AOM, Coil, Laser).

  • Derive more specific classes from the generic ones. For example, a channel of the Sinara Urukul is an RFSource, but so is a standalone AOM driver controlled by analog voltages. This allows you to easily replace components in a running experiment.

  • Reference other components, sinara devices, and HEROs in a component definition and recursively build all needed components.

Create a components.py file in your project folder. Here we define a laser cooling setup with an AOM driven by a Urukul DDS channel and a Sampler ADC for voltage readout:

Components
components.py
components = {"type": None}

# Add core stuff
components.update(
    {
        "log": {
            "classname": "atomiq.components.basics.log.KernelLogger",
        }
    }
)

# Add low level Sinara hardware
components.update(
    {
        "kasli0": {
            "classname": "atomiq.components.sinara.Kasli",
            "arguments": {},
        },
        "urukul0": {
            "classname": "atomiq.components.sinara.Urukul",
            "arguments": {
                "cpld": "@urukul0_cpld",
            },
        },
    }
)

# Add low level Sinara DIOs
components.update(
    {
        "ttl_cool": {
            "classname": "atomiq.components.sinara.DioOutput",
            "arguments": {
                "kasli": "&kasli0",
                "ttl": "@ttl4",
            },
        },
    }
)

# Add DDS channels
components.update(
    {
        "dds_cooler": {
            "classname": "atomiq.components.sinara.urukul.UrukulChannel",
            "arguments": {
                "urukul": "&urukul0",
                "device": "@urukul0_ch0",
                "ttl": "@ttl_urukul0_sw0",
            },
        },
    }
)

# Add AOMs
components.update(
    {
        "aom_cooler": {
            "classname": "atomiq.components.optoelectronics.lightmodulator.AOM",
            "arguments": {
                "rfsource": "&dds_cooler",
                "switch": "&dds_cooler",
                "center_freq": 80e6,
                "bandwidth": 40e6,
                "switching_delay": 30e-9,
                "order": 1,
                "passes": 2,
            },
        },
    }
)

# Add ADC (Sampler)
components.update(
    {
        "sampler0": {
            "classname": "atomiq.components.sinara.sampler.Sampler",
            "arguments": {
                "sampler_device": "@sampler0",
            },
        },
        "sampler0_ch0": {
            "classname": "atomiq.components.sinara.sampler.SamplerChannel",
            "arguments": {
                "adc_device": "&sampler0",
                "channel": 0,
            },
        },
    }
)

Each entry in the components dict is identified by a name that you can choose freely.

Note

Since the component dict is flat, it makes sense to think about a proper naming scheme like <component_type>_<component_name> (e.g. aom_cooler, pd_repumper, coil_north, dds_cooler, etc.).

Every component needs to define a classname which defines the class the software object should have. The arguments field takes the parameters that are needed to create the object.

Typically Atomiq components rely on other components to do their job. Such interconnections are provided by starting an argument value with a special prefix:

  • & refers to another component in the components dict (e.g. "rfsource": "&dds_cooler")

  • @ refers to a device in your ARTIQ device_db.py (e.g. "ttl": "@ttl4")

  • $ refers to a HERO in the HEROS network (e.g. "$my_camera") – more on this later

When creating the objects from the configuration, Atomiq detects these references and recursively builds the components required by the component you need.

Tip

Every component accepts the argument debug_output (default: False). To debug the behavior of a single component, set "debug_output": True in its arguments in the components dict to switch on its verbose kernel-side logging.

Tip

Atomiq comes with a lot of ready-to-use component classes. Browse the full list in the atomiq components documentation.

Writing your first experiment

Now let’s write our first Atomiq experiment. Create a repository folder and inside it a file my_experiment.py:

repository/my_experiment.py
from atomiq import AtomiqExperiment
from artiq.experiment import kernel, parallel


class LaserCoolExample(AtomiqExperiment):
    """A simple laser cooling experiment."""

    # Define components needed in the experiment. They will be built and attached to
    # the experiment object automatically. If components depend on others, these are also
    # built and initialized automatically but not attached to the experiment object.
    components = ["aom_cooler", "ttl_cool"]

    # Define experiment arguments. Scannable arguments can be scanned in the dashboard.
    arguments = {
        "on_time": {"default": 5e-6, "unit": "us", "ndecimals": 3},
        "amplitude": {"default": 0.3, "scale": 1, "precision": 3},
    }

    @kernel
    def step(self, point):
        self.log.info("Starting step with on_time {}s", (point.on_time,))

        # Set the AOM frequency and amplitude
        self.aom_cooler.detune(3e6)
        self.aom_cooler.set_amplitude(point.amplitude)

        # Switch on the AOM and the TTL simultaneously
        with parallel:
            self.aom_cooler.on()
            self.ttl_cool.pulse(point.on_time)

        self.aom_cooler.off()

Let’s break this down:

  • components is a class-level list of component names from your components dict in the device.db. Atomiq automatically builds these components (and their dependencies) and attaches them as attributes to your experiment (e.g. self.aom_cooler).

  • arguments defines the experiment arguments. By default, arguments are scannable (you can scan over them in the dashboard). Use "type": "int" for non-scannable integer arguments, or "type": "bool" for boolean switches.

  • step() is the main kernel method, decorated with @kernel. It receives a point object that gives access to the current scan point’s argument values (e.g. point.on_time).

Tip

Learn more about all available argument keywords (units, scales, precision, ranges, groups, etc.) in the arguments documentation. You can also load experiment arguments from external sources like a JSON file or a database using custom argument providers. See the Argument providers example for how to implement your own.

Note

Atomiq does not handle the initialization procedure required by some of the Sinara hardware after power cycling to ensure a fast cycle time. Please refer to the ARTIQ documentation for device initialization.

Running the experiment

To run your experiment, you need the Atomiq dashboard. You can install it on any machine that can reach your Atomiq master via network:

uv venv
uv pip install git+https://gitlab.com/atomiq-project/atomiq[gui-stable]
uv run atomiq_dashboard -s <ARTIQ_MASTER_IP_OR_HOSTNAME>

Replace <ARTIQ_MASTER_IP_OR_HOSTNAME> with the IP address or hostname of the machine running your Atomiq master.

Tip

The Atomiq dashboard is a modified version of the ARTIQ dashboard with several improvements:

  • Scannable parameters take up much less space when set to “No Scan”.

  • Drag-and-drop scan order configuration. Arguments higher in the list are scanned first (inner loop).

  • Parameter search bar to quickly find experiment arguments.

  • Better scroll behaviour and alternating parameter row highlighting.

Learn more in the frontend docs.

In the dashboard, you will see your LaserCoolExample experiment in the explorer. Select it, set your arguments, and click “Submit” to run it.

../_images/getting_started_atomiq_dashboard.png

Making it reusable with Blocks

In Atomiq you can define so-called blocks which can be used as black boxes within other Atomiq experiments or other Atomiq blocks. This allows you to work out a part of your sequence (e.g. MOT loading, transfer, imaging) once and then reuse it easily in all your other experiments.

To turn our cooling sequence into a block, create a my_blocks folder with an empty __init__.py file and a cooling.py file:

my_atomiq_lab/
├── ...
├── my_blocks/
│   ├── __init__.py       # empty file, tells Python this is a module
│   └── cooling.py
└── repository/
    └── my_experiment.py

We copy the content of our my_experiment to cooling.py:

my_blocks/cooling.py
from atomiq import AtomiqBlock
from artiq.experiment import kernel, parallel


class LaserCooling(AtomiqBlock):
    """A reusable laser cooling block."""

    components = ["aom_cooler", "ttl_cool"]

    arguments = {
        "on_time": {"default": 5e-6, "unit": "us", "ndecimals": 3},
        "amplitude": {"default": 0.3, "scale": 1, "precision": 3},
    }

    @kernel
    def step(self, point):
        self.log.info("Starting cooling step with on_time {}s", (point.on_time,))

        self.aom_cooler.detune(3e6)
        self.aom_cooler.set_amplitude(point.amplitude)

        with parallel:
            self.aom_cooler.on()
            self.ttl_cool.pulse(point.on_time)

        self.aom_cooler.off()

Making a block from an experiment is as simple as replacing AtomiqExperiment with AtomiqBlock

Now update your experiment to use the block:

repository/my_experiment.py
from atomiq import AtomiqExperiment
from artiq.experiment import kernel, delay, ms

from my_blocks.cooling import LaserCooling


class LaserCoolExample(AtomiqExperiment):
    """A simple experiment using the LaserCooling block."""

    blocks = [LaserCooling]

    @kernel
    def step(self, point):
        # Call the block's step method
        self.LaserCooling.step(point)

        # More experiment code here...
        delay(10 * ms)

To use a block, add it to the class-level blocks list. Atomiq takes care of all the housekeeping (initialization of the needed components and recursive block initialization) and imports all the block members (functions and attributes) to your experiment. You can then call the block’s methods via self.LaserCooling.step(point).

Tip

Blocks can implement the same phases as the base experiment, which are automatically called when the experiment is run. An exception is the step() method, which must be called explicitly. More to that later

Tip

You can also use the same block multiple times with different component mappings and aliases:

blocks = [
    (Imaging, {"ttl_trigger": "ttl_cool"}, "Image1"),
    (Imaging, {"ttl_trigger": "ttl_camera"}, "Image2"),
]

Learn more in the blocks documentation and the Blocks modular example.

Tip

To make custom Python modules available inside the Docker container, mount them into /git/custom:

volumes:
  - "/path/to/my_blocks:/git/custom/my_blocks"

This directory is in the Python path and every Python file in it can be imported.

Phases and Chunking

Very fast experiments with a high repetition rate (>1 Hz) are challenging. A particular problem is the time it takes the core device to switch from one run/kernel to the next. Atomiq tries to reduce this overhead by grouping together different runs in so-called chunks. Runs within a chunk are packed into the same ARTIQ kernel and are thus executed back-to-back without the timing overhead of compiling a new kernel.

In principle one would like to pack all runs into a single kernel, but memory on the core device is limited, and thus we have to compromise and find a reasonable CHUNKSIZE that can be overwritten as a class-level attribute:

class MyExperiment(AtomiqExperiment):
    CHUNKSIZE = 10
    ...

Atomiq implements several phases during the original ARTIQ run phase to optimally embed chunking and provide a fast, yet flexible interface. Each of these phases can be used in your experiment by implementing a method of the corresponding name:

Phase

Type

Description

prerun()

Kernel

Executed once at the beginning of the run phase. Also executed again before continuing with the next chunk if an error occurred.

prerun_host()

Host

Executed once at the beginning of the run phase. Use for slow device initialization.

postrun()

Kernel

Executed once at the end of the run phase. Bring hardware into a safe state.

postrun_host()

Host

Executed once at the end of the run phase. Disconnect or power down external devices.

prechunk(points)

Kernel

Executed before each chunk. Run fast calibrations.

prechunk_host(points)

Host

Executed before each chunk. Configure external devices for the upcoming chunk.

postchunk(points)

Kernel

Executed after each chunk. Save data or perform chunk-specific cleanup.

postchunk_host(points)

Host

Executed after each chunk. Reset or adjust external devices.

prestep(point)

Kernel

Executed before each step. E.g. reset laser stabilization integrators.

step(point)

Kernel

Main experiment phase. This is where your experiment sequence goes.

poststep(point)

Kernel

Executed after each step. E.g. save data collected during the step.

Warning

All kernel phases in your experiment (and your blocks) must be decorated with the @kernel decorator.

Note

All phases can be implemented also in blocks. They are executed automatically just like the ones in the main experiment. One exception is the step method, which is not automatically called.

The chunk and step phases provide information about the current scan point(s) via the point (or points for the chunk phases) argument. The values of experiment arguments of the current step can be accessed via point.my_argument. Additionally, the following attributes are provided:

  • point.step_counter: The overall sequential number of the point, starting from 0.

  • point.run_id: The ARTIQ RID, the overall sequential number of the experiment.

  • point.identifier: A unique identifier of the run.

Tip

In the chunk phases (prechunk, postchunk, prechunk_host, postchunk_host), the points argument is a Chunk (host phases) or KernelChunk (kernel phases) object that contains all scan points of the current chunk. You can access attributes that are constant across the chunk directly via points.my_argument. Atomiq automatically checks consistency: if you try to access an attribute that varies within the chunk, a RuntimeError is raised. To iterate over individual points in kernel phases, use points.to_list().

Here is an example using several phases:

repository/my_experiment.py
from atomiq import AtomiqExperiment
from artiq.experiment import kernel, delay, ms

from my_blocks.cooling import LaserCooling


class CoolImagingExample(AtomiqExperiment):
    """Cooling followed by imaging, using phases."""

    blocks = [LaserCooling]

    components = ["ttl_cool"]

    arguments = {
        "cooling_time": {"default": 5e-6, "unit": "us", "ndecimals": 3},
    }

    @kernel
    def prerun(self):
        # Kernel-side initialization, runs once per run
        self.log.info("Experiment starting")

    def prerun_host(self):
        # Host-side initialization, runs once per run
        # e.g. configure a slow camera here
        pass

    @kernel
    def prechunk(self, points):
        # Runs once per chunk, before the steps
        # points is a KernelChunk; access invariant attributes directly
        self.log.info("Starting chunk with cooling_time {}s", (points.cooling_time,))

    @kernel
    def step(self, point):
        # Main experiment sequence
        self.LaserCooling.step(point)
        self.ttl_cool.pulse(10 * ms)

    @kernel
    def poststep(self, point):
        # Runs after each step
        self.log.info("Step {} done", (point.step_counter,))

    @kernel
    def postrun(self):
        # Kernel-side cleanup, runs once per run
        self.log.info("Experiment finished")

Tip

Learn more about phases, chunking, and accessing scan point information in the chunking documentation and scan point docs.

Integrating HEROS

Atomiq is perfectly suited to describe and control the realtime hardware in your setup. However, a good deal of the hardware orchestration concerns non-realtime devices that are not directly connected to the realtime control system. To make all these devices available in your Atomiq experiments, the HEROS () framework is used. The idea behind HEROS is that every hardware object becomes a software object that can be transparently accessed via the network.

If you haven’t already, we recommend completing the Getting Started with HEROS tutorial first to set up your non-realtime devices.

Using HEROs as components

Using an available HERO in your Atomiq experiment is as easy as putting its name into your components list, prefixed with $. Let’s assume there is a camera available as a HERO with the name my_camera in the HEROS realm my_realm (the realm you set with --heros-realm when starting the Atomiq master):

repository/my_imaging.py
from atomiq import AtomiqExperiment
from artiq.experiment import kernel, us

from my_blocks.cooling import LaserCooling


class CoolImagingExample(AtomiqExperiment):
    """Cooling followed by camera imaging."""

    blocks = [LaserCooling]

    components = ["$my_camera", "ttl_cool"]

    arguments = {
        "exposure_time": {"default": 100e-6, "unit": "us", "ndecimals": 3},
    }

    def prerun_host(self):
        # Configure the camera from the host side
        self.my_camera.stop()
        self.my_camera.update_configuration(
            {"default": {"exposure_time": self.exposure_time.value}}
        )
        self.my_camera.configure(
            "default",
            metadata={"save_name": "MOTImage", "n_images": 1},
        )
        self.my_camera.arm()

    @kernel
    def step(self, point):
        # Run the cooling block
        self.LaserCooling.step(point)

        # Hardware trigger for the camera
        self.ttl_cool.pulse(2 * us)

        # Software trigger via RPC
        self.my_camera.start()

Any HERO imported this way inherits from Component inside Atomiq. However, if the HERO specifies in its metadata that it is compatible with a more specific class inside Atomiq, then it is based off this more specific class and Atomiq assumes that the necessary interface exists in the HERO. This way, it is possible to have non-realtime devices like RF sources, voltage and current sources, ADCs, simple switches, etc. look exactly like their realtime counterparts from a software perspective.

Note

Dashes - in the HERO name are replaced by _ in the attribute name. E.g. $my-camera becomes available as self.my_camera.

Tip

Inside Atomiq, you can use all methods, attributes, and events that the HERO exposes. If your HERO carries proper type annotations, you can also access the HERO from within kernel code sections (this issues an RPC call into the ARTIQ master which then calls the HERO).

Learn more in the HEROS in Atomiq documentation.

Broadcasting data with HEROSink

Now we can run experiments, build blocks and use HEROS. In the Getting Started with HEROS tutorial we also learned how to emit and process data from HEROs using events. In Atomiq we have a similar mechanism to emit data from an experiment (including from kernel sequences) using a HEROSink. This is very useful to get data about the current experiment and progress of scanning, or measuring adc voltages. With this mechanism, we can fully abandon the ARTIQ .h5 file mechanism and instead store everything in easily indexable S3 compatible storage and databases. You can learn more about that in the Evaluation and Data Storage tutorial.

To broadcast data from your experiment into the HEROS network, add a HEROSink component:

components.py (excerpt)
components["herosink"] = {
    "classname": "atomiq.components.basics.datasink.HEROSink"
}

Then use it in your experiment or block:

from atomiq import AtomiqBlock
from artiq.experiment import kernel, parallel


class LaserCooling(AtomiqBlock):

    components = ["aom_cooler", "ttl_cool", "herosink", "sampler0_ch0"]

    arguments = {
        "on_time": {"default": 5e-6, "unit": "us", "ndecimals": 3},
        "amplitude": {"default": 0.3, "scale": 1, "precision": 3},
    }

    @kernel
    def step(self, point):
        # Emit the current argument values as environment data
        self.herosink.submit_env(point)

        # Measure the input voltage and emit it as custom data
        voltage = self.sampler0_ch0.measure()
        self.herosink.submit_data(["my_input_voltage"], [voltage])

        # ... rest of the cooling sequence

Tip

The submit_env method emits the values of all experiment arguments for the current step, together with metadata like timestamp, run_id, step_counter, and identifier. Learn more about the HEROSink class.

Monitoring running experiments

All running experiments are HEROs as well. This allows you to interact with the living experiment from remote. To gather the emitted data we have to subscribe to the emit_data event of the experiment HERO.

The following script watches for experiment start/end events on the scheduler and collects all data emitted by the running experiment. Note that collect_data receives both submit_env data (containing the argument values like on_time) and submit_data data (containing the measured my_input_voltage):

experiment_monitor.py
from heros import EventObserver, RemoteHERO

on_times = []
my_input_voltages = []


def collect_data(data):
    print("Received:", data)
    if "on_time" in data:
        on_times.append(data["on_time"])
    if "my_input_voltage" in data:
        my_input_voltages.append(data["my_input_voltage"])


def experiment_started(origin: str, metadata: dict):
    global on_times, my_input_voltages
    print(f"Experiment {metadata['name']} started (RID {metadata['rid']})")
    on_times = []
    my_input_voltages = []
    experiment_hero = RemoteHERO(metadata["hero_name"], realm="my_realm")
    experiment_hero.emit_data.connect(collect_data)


def experiment_ended(origin: str, metadata: dict):
    print(f"Experiment finished. Collected {len(on_times)} data points:")
    print("on_times:", on_times)
    print("my_input_voltages:", my_input_voltages)


start_observer = EventObserver("atomiq-scheduler-my_master", "run_started", realm="my_realm")
end_observer = EventObserver("atomiq-scheduler-my_master", "run_ended", realm="my_realm")
start_observer.register_callback(experiment_started)
end_observer.register_callback(experiment_ended)

Run it with:

uv run experiment_monitor.py

and watch the output.

Tip

The Atomiq scheduler and experiment database are HEROs themselves. This allows you to schedule experiments programmatically, duplicate runs, and interact with running experiments from any machine on the network. Learn more in the HEROS in Atomiq documentation.

Where to go from here

Congratulations! You now know the core concepts of Atomiq: components, blocks, chunking/phases, and the HEROS integration. Here are some suggestions for what to explore next: