03 - Advanced Timing with tProc v2

Objective: Understand the critical differences between delay(), wait(), delay_auto(), and wait_auto(). Learn how to manage the timeline correctly to avoid pulse collisions and timing errors.

1. Setup

[1]:
# Standard imports
%matplotlib inline
import matplotlib.pyplot as plt
import numpy as np
import logging

from qick import *
from qick.asm_v2 import AveragerProgramV2, QickSweep1D

# Setup logging
logging.basicConfig(level=logging.INFO, format='%(levelname)-8s [%(filename)s:%(lineno)d] %(message)s')
logging.getLogger("qick_processor").setLevel(logging.WARNING)

# Connect to the board (adjust the path to your firmware)
BITSTREAM_PATH = 'qick_216.bit'#'/path/to/your/firmware.bit'  # <--- CHANGE THIS
soc = QickSoc(BITSTREAM_PATH)
soccfg = soc

# Define hardware channels
GEN_CH = 0
RO_CH = 0

2. Understanding the Timeline

In tProc v2, all timed instructions (pulses, triggers, delays) are scheduled on a common timeline. The tProc maintains a reference time that advances as instructions are executed.

Key concepts:

  • Absolute time: The actual time from the start of the shot

  • Reference time: The tProc’s internal clock, which determines when the next instruction will execute

  • Scheduled time: The time you specify for a pulse or trigger (e.g., t=0.5)

The four timing commands:

Command

What it does

When to use

delay(t)

Adds t to the reference time

When you know exactly how long to wait

wait(t)

Waits until the reference time reaches t

When you need to wait for a specific absolute time

delay_auto(t, gens=True, ros=True)

Adds t PLUS the time needed to complete all pending gens/ROs

Safe way to ensure everything finishes

wait_auto(t, gens=False, ros=True)

Waits until reference time reaches t OR pending operations finish

Similar to v1’s sync_all()

3. Basic Timing: delay() vs wait()

The simplest timing commands are delay() and wait(). They work with absolute and relative timing.

[2]:
class TimingExampleProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        ro_ch = cfg['ro_ch']
        gen_ch = cfg['gen_ch']

        self.declare_gen(ch=gen_ch, nqz=1)
        self.declare_readout(ch=ro_ch, length=cfg['ro_len'])

        # Define a constant pulse
        self.add_pulse(ch=gen_ch, name="test_pulse",
                       style="const",
                       freq=cfg['freq'], ro_ch=ro_ch,
                       length=cfg['pulse_len'],
                       phase=0, gain=1.0)

        self.add_readoutconfig(ch=ro_ch, name="my_ro",
                               freq=cfg['freq'], gen_ch=gen_ch)
        self.send_readoutconfig(ch=ro_ch, name="my_ro", t=0)

    def _body(self, cfg):
        # Trigger readout at t=0.5 µs
        self.trigger(ros=[cfg['ro_ch']], pins=[0], t=0.5)

        # First pulse at t=0
        self.pulse(ch=cfg['gen_ch'], name="test_pulse", t=0)

        if cfg['use_wait']:
            # Using wait: pause until reference time reaches 0.8 µs
            self.wait(0.8)
        else:
            # Using delay: add 0.8 µs to reference time
            self.delay(0.8)

        # Second pulse - when does it play?
        self.pulse(ch=cfg['gen_ch'], name="test_pulse", t=0)

# Compare delay vs wait
config = {
    'gen_ch': GEN_CH,
    'ro_ch': RO_CH,
    'freq': 500,
    'pulse_len': 0.1,
    'ro_len': 2.0,
    'use_wait': False
}

print("=== Using DELAY ===")
prog = TimingExampleProgram(soccfg, reps=1, final_delay=0.5, cfg=config)

# Get the compiled program as text to inspect the timing
print(prog.asm())

print("\n=== Using WAIT ===")
config['use_wait'] = True
prog = TimingExampleProgram(soccfg, reps=1, final_delay=0.5, cfg=config)
print(prog.asm())
WARNING  [asm_v2.py:998] warning: pulse time 0 appears to conflict with previous pulse ending at <qick.asm_v2.QickParam object at 0xffff8e46de70>?
=== Using DELAY ===
     NOP
     REG_WR s12 imm #0
     WPORT_WR p4 wmem [&1] @0
     TIME #430 inc_ref
     REG_WR r0 imm #0
reps:
     TRIG p0 set @215
     TRIG p10 set @215
     TRIG p0 clr @225
     TRIG p10 clr @225
     WPORT_WR p0 wmem [&0] @0
     TIME #344 inc_ref
     WPORT_WR p0 wmem [&0] @0
     REG_WR s15 label SKIP
     WAIT [s15] @731 time
     TIME #946 inc_ref
     REG_WR s12 op -op(s12 + #1)
     REG_WR s15 label reps
     TEST -op(r0 - #0)
     JUMP [s15] -if(NZ) -wr(r0 op) -op(r0 + #1)
     REG_WR s15 label NEXT
     JUMP [s15]


=== Using WAIT ===
     NOP
     REG_WR s12 imm #0
     WPORT_WR p4 wmem [&1] @0
     TIME #430 inc_ref
     REG_WR r0 imm #0
reps:
     TRIG p0 set @215
     TRIG p10 set @215
     TRIG p0 clr @225
     TRIG p10 clr @225
     WPORT_WR p0 wmem [&0] @0
     REG_WR s15 label SKIP
     WAIT [s15] @344 time
     WPORT_WR p0 wmem [&0] @0
     REG_WR s15 label SKIP
     WAIT [s15] @1075 time
     TIME #1290 inc_ref
     REG_WR s12 op -op(s12 + #1)
     REG_WR s15 label reps
     TEST -op(r0 - #0)
     JUMP [s15] -if(NZ) -wr(r0 op) -op(r0 + #1)
     REG_WR s15 label NEXT
     JUMP [s15]

Key difference:

  • delay(0.8) adds 0.8 µs to the reference time, so the second pulse plays 0.8 µs after the first pulse ends

  • wait(0.8) pauses until the reference time reaches exactly 0.8 µs (absolute time from shot start)

Rule of thumb: Use delay() for relative timing between pulses. Use wait() for absolute timing or when waiting for a specific absolute time.

4. Auto-Timing: delay_auto() and wait_auto()

The _auto variants automatically account for pending operations (pulses that haven’t finished, readouts that are still acquiring). This is safer when you’re not sure how long previous operations will take.

[3]:
class AutoTimingProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        ro_ch = cfg['ro_ch']
        gen_ch = cfg['gen_ch']

        self.declare_gen(ch=gen_ch, nqz=1)
        self.declare_readout(ch=ro_ch, length=cfg['ro_len'])

        # Define pulses with swept lengths
        self.add_pulse(ch=gen_ch, name="pulse_a",
                       style="const",
                       freq=cfg['freq'], ro_ch=ro_ch,
                       length=cfg['len_a'],
                       phase=0, gain=1.0)

        self.add_pulse(ch=gen_ch, name="pulse_b",
                       style="const",
                       freq=cfg['freq'], ro_ch=ro_ch,
                       length=cfg['len_b'],
                       phase=0, gain=1.0)

        self.add_readoutconfig(ch=ro_ch, name="my_ro",
                               freq=cfg['freq'], gen_ch=gen_ch)
        self.send_readoutconfig(ch=ro_ch, name="my_ro", t=0)

    def _body(self, cfg):
        # Play first pulse at t=0
        self.pulse(ch=cfg['gen_ch'], name="pulse_a", t=0)

        # Auto-delay: wait for pulse_a to finish, then add extra time
        # ros=False means don't wait for readout (which hasn't started yet)
        self.delay_auto(cfg['gap'], ros=False)

        # Play second pulse
        self.pulse(ch=cfg['gen_ch'], name="pulse_b", t=0)

        # Trigger readout after both pulses
        self.trigger(ros=[cfg['ro_ch']], pins=[0], t=cfg['trig_time'])

# Demo: pulse lengths vary, but the gap between them stays constant
config_auto = {
    'gen_ch': GEN_CH,
    'ro_ch': RO_CH,
    'freq': 500,
    'len_a': 0.2,      # µs
    'len_b': 0.3,      # µs (different length!)
    'gap': 0.1,        # µs gap between pulses
    'trig_time': 0.8,  # µs
    'ro_len': 1.5      # µs
}

prog = AutoTimingProgram(soccfg, reps=1, final_delay=0.5, cfg=config_auto)

# Print the compiled program to see the generated assembly
print(prog.asm())
     NOP
     REG_WR s12 imm #0
     WPORT_WR p4 wmem [&2] @0
     TIME #430 inc_ref
     REG_WR r0 imm #0
reps:
     WPORT_WR p0 wmem [&0] @0
     TIME #129 inc_ref
     WPORT_WR p0 wmem [&1] @0
     TRIG p0 set @344
     TRIG p10 set @344
     TRIG p0 clr @354
     TRIG p10 clr @354
     REG_WR s15 label SKIP
     WAIT [s15] @989 time
     TIME #1205 inc_ref
     REG_WR s12 op -op(s12 + #1)
     REG_WR s15 label reps
     TEST -op(r0 - #0)
     JUMP [s15] -if(NZ) -wr(r0 op) -op(r0 + #1)
     REG_WR s15 label NEXT
     JUMP [s15]

Why ``delay_auto()`` is powerful:

  • It automatically calculates when pulse A ends

  • Then schedules pulse B to start at (end_of_pulse_A + gap)

  • This works even if pulse lengths change (e.g., in a sweep)

Parameters:

  • gens=True/False: Whether to wait for generator operations (pulses)

  • ros=True/False: Whether to wait for readout operations

  • Default for delay_auto(): gens=True, ros=True

  • Default for wait_auto(): gens=False, ros=True (like v1’s sync_all)

5. Common Pitfall: Pulse Collisions

If you schedule a pulse before the previous one finishes, the tProc will give you a warning (if you have logging enabled) but will still execute. This can cause unexpected results.

[4]:
class CollisionProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        self.declare_gen(ch=cfg['gen_ch'], nqz=1)
        self.declare_readout(ch=cfg['ro_ch'], length=cfg['ro_len'])

        self.add_pulse(ch=cfg['gen_ch'], name="long_pulse",
                       style="const",
                       freq=cfg['freq'], ro_ch=cfg['ro_ch'], length=0.5, phase=0, gain=1.0)

        self.add_readoutconfig(ch=cfg['ro_ch'], name="my_ro",
                               freq=cfg['freq'], gen_ch=cfg['gen_ch'])
        self.send_readoutconfig(ch=cfg['ro_ch'], name="my_ro", t=0)

    def _body(self, cfg):
        # First pulse at t=0
        self.pulse(ch=cfg['gen_ch'], name="long_pulse", t=0)

        # This pulse is scheduled while the first is still playing!
        # The tProc will issue a warning.
        self.pulse(ch=cfg['gen_ch'], name="long_pulse", t=0.3)

        self.trigger(ros=[cfg['ro_ch']], pins=[0], t=0.6)

config_collision = {
    'gen_ch': GEN_CH,
    'ro_ch': RO_CH,
    'freq': 500,
    'ro_len': 1.0
}

prog = CollisionProgram(soccfg, reps=1, final_delay=0.5, cfg=config_collision)
print("Program with potential pulse collision:")
print(prog.asm())
print("\nNote: The second pulse is scheduled before the first finishes!")
WARNING  [asm_v2.py:998] warning: pulse time 0.3 appears to conflict with previous pulse ending at <qick.asm_v2.QickParam object at 0xffff649029e0>?
Program with potential pulse collision:
     NOP
     REG_WR s12 imm #0
     WPORT_WR p4 wmem [&1] @0
     TIME #430 inc_ref
     REG_WR r0 imm #0
reps:
     WPORT_WR p0 wmem [&0] @0
     WPORT_WR p0 wmem [&0] @129
     TRIG p0 set @258
     TRIG p10 set @258
     TRIG p0 clr @268
     TRIG p10 clr @268
     REG_WR s15 label SKIP
     WAIT [s15] @688 time
     TIME #903 inc_ref
     REG_WR s12 op -op(s12 + #1)
     REG_WR s15 label reps
     TEST -op(r0 - #0)
     JUMP [s15] -if(NZ) -wr(r0 op) -op(r0 + #1)
     REG_WR s15 label NEXT
     JUMP [s15]


Note: The second pulse is scheduled before the first finishes!

How to fix: Use delay_auto() to ensure proper spacing.

# Instead of:
self.pulse(ch=gen_ch, name="long_pulse", t=0)
self.pulse(ch=gen_ch, name="long_pulse", t=0.3)  # Collision!

# Do this:
self.pulse(ch=gen_ch, name="long_pulse", t=0)
self.delay_auto(0.1)  # Wait for pulse to finish, then add 0.1 µs
self.pulse(ch=gen_ch, name="long_pulse", t=0)  # Safely scheduled

6. Sweeping Delays

You can sweep delay times just like pulse parameters.

[5]:
class DelaySweepProgram(AveragerProgramV2):
    def _initialize(self, cfg):
        self.declare_gen(ch=cfg['gen_ch'], nqz=1)
        self.declare_readout(ch=cfg['ro_ch'], length=cfg['ro_len'])

        self.add_loop("delay_sweep", self.cfg["steps"])

        self.add_pulse(ch=cfg['gen_ch'], name="test_pulse",
                       style="const",
                       freq=cfg['freq'], ro_ch=cfg['ro_ch'], length=0.1,
                       phase=0, gain=1.0)

        self.add_readoutconfig(ch=cfg['ro_ch'], name="my_ro",
                               freq=cfg['freq'], gen_ch=cfg['gen_ch'])
        self.send_readoutconfig(ch=cfg['ro_ch'], name="my_ro", t=0)

    def _body(self, cfg):
        # Trigger readout at fixed time
        self.trigger(ros=[cfg['ro_ch']], pins=[0], t=0.5)

        # First pulse at t=0
        self.pulse(ch=cfg['gen_ch'], name="test_pulse", t=0)

        # Sweep the delay between pulses
        self.delay(QickSweep1D("delay_sweep", 0.1, 0.5))

        # Second pulse
        self.pulse(ch=cfg['gen_ch'], name="test_pulse", t=0)

config_delay_sweep = {
    'steps': 5,
    'gen_ch': GEN_CH,
    'ro_ch': RO_CH,
    'freq': 500,
    'ro_len': 1.0
}

prog = DelaySweepProgram(soccfg, reps=1, final_delay=0.5, cfg=config_delay_sweep)

# Show that the program compiles with swept delays
print("Program with swept delay:")
print(prog.asm())
print("\nNote: The tProc will automatically handle the varying delay times.")
Program with swept delay:
     NOP
     REG_WR r2 imm #43
     REG_WR r3 imm #817
     REG_WR s12 imm #0
     WPORT_WR p4 wmem [&1] @0
     TIME #430 inc_ref
     REG_WR r0 imm #0
reps:
     REG_WR r1 imm #0
delay_sweep:
     TRIG p0 set @215
     TRIG p10 set @215
     TRIG p0 clr @225
     TRIG p10 clr @225
     WPORT_WR p0 wmem [&0] @0
     TIME inc_ref r2
     WPORT_WR p0 wmem [&0] @0
     REG_WR s15 label SKIP
     WAIT [s15] @602 time
     TIME inc_ref r3
     REG_WR s12 op -op(s12 + #1)
     REG_WR r2 op -op(r2 + #43)
     REG_WR r3 op -op(r3 + #-43)
     REG_WR s15 label delay_sweep
     TEST -op(r1 - #4)
     JUMP [s15] -if(NZ) -wr(r1 op) -op(r1 + #1)
     REG_WR r2 op -op(r2 + #-215)
     REG_WR r3 op -op(r3 + #215)
     REG_WR s15 label reps
     TEST -op(r0 - #0)
     JUMP [s15] -if(NZ) -wr(r0 op) -op(r0 + #1)
     REG_WR s15 label NEXT
     JUMP [s15]


Note: The tProc will automatically handle the varying delay times.

7. Summary

You have learned:

  • The difference between delay() (relative) and wait() (absolute)

  • How delay_auto() and wait_auto() automatically account for pending operations

  • How to avoid pulse collisions by using proper timing commands

  • How to sweep delay times

  • How to inspect the generated assembly code using asm() to verify timing

Best practices:

  1. Use ``delay_auto()`` when you want to wait for pulses to finish - It’s safer and works with swept lengths

  2. Use ``wait()`` for absolute timing - When you need to hit an exact absolute time

  3. Use ``delay()`` for simple relative timing - When you know exactly how long to wait

  4. Always check for pulse collisions - Use print(prog.asm()) to inspect the generated code

  5. Use tags to identify instructions - Tags help with debugging and retrieving parameters

Next steps: Proceed to `04_Real_Time_Feedback.ipynb <./04_Real_Time_Feedback.ipynb>`__ to learn how to read measurement results and make decisions on the tProc in real-time.