10 - Multi-Board Synchronization

Objective: Learn how to synchronize multiple QICK boards using external clock and external start signals. This notebook covers:

  • External clock configuration for frequency synchronization

  • External start pins for tProc synchronization

  • Leader-follower synchronization scheme

  • Software synchronization for multi-board experiments

  • Measuring inter-board jitter and timing accuracy

  • Practical examples for tProc v1 and v2 firmwares

Prerequisites:

  • Two or more QICK boards

  • Stable external reference clock (e.g., rubidium clock, 10 MHz reference)

  • PMOD jumper wires for start signal connection

  • Coaxial cables for RF loopback between boards

  • Completed basic tutorials (00-05)

References:

1. Overview

QICK provides two key features for multi-board synchronization:

  1. External Clock: The clock reference chips on the RFSoC board can be configured to lock to an external reference.

  2. External Start: The tProc can be configured to wait for an external pulse before starting execution of a program.

These features can be used together to run synchronized programs on multiple boards. The general idea:

  • All boards are locked to a common reference clock

  • The “leader” board sends a pulse on one of the PMOD pins at the beginning of its program

  • The “follower” boards are configured for external start, so their tProcs only start execution when the pulse arrives at the external-start pin (another PMOD pin)

  • The jitter between boards is governed by the stability of the board clocks (sub-100 ps achievable)

Hardware Requirements

┌─────────────────────────────────────────────────────────────────────────────┐
│                         Multi-Board Synchronization                         │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│   ┌──────────┐                      ┌───────────┐                           │
│   │ QICK #1  │  PMOD0_0 ──► PMOD1_0 │ QICK #2   │──────                     │
│   │ (Leader) │                      │ (Follower)│     |                     │
│   └────┬─────┘                      └────┬──────┘     |                     │
│        │                                 │            |                     │
│        │   ┌─────────────────────────────────────┐    |                     │
│        └──►│     External Reference Clock        │◄───┘                     │
│            │    (e.g., 12.8 MHz for ZCU111)      │                          │
│            └─────────────────────────────────────┘                          │
│                                                                             │
│   • Leader PMOD0_0 → Follower PMOD1_0 (start signal)                        │
│   • Coaxial cables for DAC→ADC loopback between boards                      │
│   • Shared external reference clock for frequency sync                      │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Important notes:

  • tProc v1 firmwares have always supported external start

  • tProc v2 requires revision 24 or later for stable external start

  • The phase relationship between tProc clocks is random; if you see a one-tick jitter (<10% probability), re-initialize the leader or follower

  • This scheme works for multiple followers (just wire all PMOD1_0 pins to the leader’s PMOD0_0)

  • The jitter measurement technique here is limited by interpolation between decimated data points; for better measurements, use a scope or time-tagger readout

2. Setup and Initialization

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

from qick import *
from qick.asm_v2 import AveragerProgramV2

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

# Import nest_asyncio for Jupyter compatibility
import nest_asyncio
nest_asyncio.apply()

print("Setup complete.")

2.1 Connect to Multiple Boards via Pyro

For multi-board experiments, we’ll use Pyro to connect to each board independently. Start the Pyro servers on each board first:

# On each board (e.g., qick1 and qick2)
from qick.pyro import start_server
start_server(
    ns_host="your_nameserver_host",
    ns_port=8888,
    proxy_name="qick1",  # or "qick2"
    external_clk=True,    # Important: use external reference
    adc_sample_rates={0:3072.0}  # Optional: set ADC sample rate
)
[ ]:
from qick.pyro import make_proxy

# Connect to boards via Pyro (use your actual host IP and port)
NS_HOST = "localhost"  # Change to your nameserver host
NS_PORT = 8888

proxies = []
for name in ['qick1', 'qick2']:
    proxies.append(make_proxy(ns_host=NS_HOST, ns_port=NS_PORT, proxy_name=name))

# Display board configurations
for i, (soc, soccfg) in enumerate(proxies):
    print(f"\n=== Board {i+1} ===")
    print(f"Firmware: {soccfg.get('fw_version', 'Unknown')}")
    print(f"tProc cores: {soc.num_tprocs}")
    print(f"External clock: {soccfg.get('external_clk', False)}")

2.2 Utility Functions

These helper functions will be used for data analysis.

[ ]:
def get_inphase(iq):
    """
    Takes an IQ array, rotates it so the average is on the positive real axis,
    and returns the real component.
    This is the component of the data that's in phase with the signal.
    Useful because unlike the magnitude, this is zero-centered in the absence of signal.
    """
    x = iq.dot([1, 1j])
    x_rotated = x * np.exp(-1j * np.angle(x.mean()))
    return np.real(x_rotated)

def find_edges(inphase, trim=7):
    """
    Find rising and falling edges of a rectangular pulse.
    Uses linear interpolation around a threshold, where the threshold is half the pulse height.
    The pulse height is measured by finding the pulse, trimming the specified number of samples on either end, and averaging.

    Parameters:
    - inphase: In-phase component of the signal
    - trim: Number of samples to trim from pulse ends for height measurement

    Returns:
    - rising: Rising edge time (interpolated)
    - falling: Falling edge time (interpolated)
    - flatheight: Average height of the pulse flat top
    """
    # Find the mean height in the flat part of the pulse
    x = inphase - 0.5 * inphase.max()
    ispulse = (x > 0)
    pulserange = (np.argmax(ispulse), ispulse.size - np.argmax(ispulse[::-1]))
    flat = inphase[pulserange[0] + trim:pulserange[1] - trim]
    flatheight = flat.mean()

    # Get a more precise estimate of the rising and falling edges
    # Use linear interpolation between samples
    x = inphase - 0.5 * flatheight
    ispulse = (x > 0)

    # Indices of the samples after the rising/falling edges
    rising = np.argmax(ispulse)
    falling = ispulse.size - np.argmax(ispulse[::-1])

    # Linear interpolation between the points before/after the zero crossing
    rising -= x[rising] / (x[rising] - x[rising - 1])
    falling -= x[falling] / (x[falling] - x[falling - 1])

    return rising, falling, flatheight

def plot_jitter(plot, x, y):
    """
    Scatterplot two pulse times' deviation from the median.
    Scales the axes to maintain a square aspect ratio.
    """
    x = x - np.median(x)
    y = y - np.median(y)
    plot.plot(x, y, '.')
    maxrange = max([np.abs(a).max() for a in [x, y]])
    plot.set_xlim((-1.1 * maxrange, 1.1 * maxrange))
    plot.set_ylim((-1.1 * maxrange, 1.1 * maxrange))

print("Utility functions defined.")

2.3 Software Synchronization

We need to run programs on multiple boards at the same time. The follower should enter external-start mode before the leader sends the start pulse. This requires software synchronization.

For each program execution (“round”), we wait until all boards are ready to run, then run the round. This is done using the step_rounds argument to acquire_decimated().

For reference, this is how you’d use this when running a single board:

prog.acquire(soc, start_src='internal', step_rounds=True)
while prog.finish_round():
    prog.prepare_round()
iq_list = prog.finish_acquire()
[ ]:
async def wrap_round(loop, prog):
    """Wrapper for async round completion check."""
    result = await loop.run_in_executor(None, prog.finish_round)
    return result

async def run_multi_decimated(loop, proxies, progs, **kwargs):
    """
    Run a set of programs on a multi-board setup, in decimated mode.

    Parameters:
    - loop: asyncio event loop
    - proxies: list of (soc, soccfg) tuples, leader first
    - progs: list of programs, same order as proxies
    - other arguments passed through to acquire_decimated

    Returns:
    - List of acquired data from each program
    """
    # Initialize all programs
    for i, prog in enumerate(progs):
        soc, soccfg = proxies[i]
        start_src = 'internal' if i == 0 else 'external'
        prog.acquire_decimated(soc,
                               start_src=start_src,
                               step_rounds=True,
                               **kwargs)

    # Execute rounds synchronously
    while True:
        async with asyncio.TaskGroup() as tg:
            tasks = []
            for prog in progs:
                tasks.append(tg.create_task(wrap_round(loop, prog)))

        results = [task.result() for task in tasks]
        if not any(results):
            break
        if not all(results):
            raise RuntimeError(f"Some done, some not: {results}")

        for prog in progs:
            prog.prepare_round()

    return [prog.finish_acquire() for prog in progs]

print("Async wrapper functions defined.")

3. tProc v1 Example

Start with tProc v1 because we know external start works. The standard firmware is fine.

Firmware requirements:

  • Any tProc v1 firmware with an external start pin - this is most firmwares

  • For tProc v2, you need revision 24 or later

Channel configuration:

  • DAC 229_3 to ADC 224_0 (loopback within board)

  • DAC 229_2 to ADC 224_1 (loopback between boards)

[ ]:
# Channel configuration for tProc v1
GEN_CHS = [6, 5]
RO_CHS = [0, 1]
TRIG_TIME = 145  # tProc ticks
FREQ = 500  # MHz

print(f"Generator channels: {GEN_CHS}")
print(f"Readout channels: {RO_CHS}")
print(f"Frequency: {FREQ} MHz")
[ ]:
class LoopbackProgram(AveragerProgram):
    """
    Loopback program for tProc v1.
    Generates a pulse and measures it on the same board or across boards.
    """
    def initialize(self):
        cfg = self.cfg
        for gen_ch in cfg["gen_chs"]:
            self.declare_gen(ch=gen_ch, nqz=1)
        for ch in cfg["ro_chs"]:
            self.declare_readout(ch=ch, length=cfg["ro_len"],
                                 freq=cfg["freq"], gen_ch=cfg["gen_chs"][0])

        freq = self.freq2reg(cfg["freq"], gen_ch=cfg["gen_chs"][0], ro_ch=cfg["ro_chs"][0])
        phase = self.deg2reg(cfg["gen_phase"], gen_ch=cfg["gen_chs"][0])
        gain = cfg["gen_gain"]

        for gen_ch in cfg["gen_chs"]:
            # The minimum length for a waveform is 3 fabric ticks (48 DAC samples)
            # Create a custom envelope with ramps
            maxv = self.get_maxv(gen_ch)
            idata = np.ones(6*16, dtype=np.int32) * maxv
            idata[:cfg['ramp_nsamp']] = np.linspace(0, maxv, cfg['ramp_nsamp'], dtype=np.int32)
            idata[:-cfg['ramp_nsamp']-1:-1] = np.linspace(0, maxv, cfg['ramp_nsamp'], dtype=np.int32)
            qdata = np.zeros(6*16, dtype=np.int32)
            self.add_envelope(ch=gen_ch, name="ramp", idata=idata, qdata=qdata)
            self.set_pulse_registers(ch=gen_ch, freq=freq, phase=phase, gain=gain,
                                     style="flat_top", length=cfg["pulse_len"],
                                     waveform='ramp')

        if cfg['send_start']:
            self.synci(200)  # give processor some time to set registers
            self.trigger(pins=[0], t=0)  # output a pulse on PMOD0_0, to trigger the follower
            self.synci(cfg['leader_delay'])  # give follower time to catch up
        self.synci(200 + cfg['follower_delay'])  # give processor some time to set registers

    def body(self):
        self.trigger(adcs=self.cfg["ro_chs"],
                     adc_trig_offset=self.cfg["adc_trig_offset"])
        for gen_ch in self.cfg["gen_chs"]:
            self.pulse(ch=gen_ch)
        self.wait_all()
        self.sync_all(self.us2cycles(self.cfg["relax_delay"]))

# Base configuration
config = {
    "gen_chs": GEN_CHS,
    "ro_chs": RO_CHS,
    "leader_delay": 10,  # tProc ticks
    "follower_delay": 0,  # tProc ticks
    "relax_delay": 1.0,  # us
    "gen_phase": 0,  # degrees
    "pulse_len": 20,  # gen ticks
    "ramp_nsamp": 32,  # DAC samples
    "ro_len": 50,  # RO ticks
    "gen_gain": 30000,  # DAC units
    "freq": FREQ,  # MHz
    "adc_trig_offset": TRIG_TIME,  # tProc ticks
    "send_start": False,
    "reps": 1,
    "soft_avgs": 100
}

print("Loopback program defined.")

3.1 Test One Board at a Time

First, run each board independently to verify the setup.

[ ]:
progs = []
config['send_start'] = False

for i, (soc, soccfg) in enumerate(proxies):
    progs.append(LoopbackProgram(soccfg, config))

iq_lists = []
for i, (soc, soccfg) in enumerate(proxies):
    print(f"Acquiring data from board {i+1}...")
    iq_lists.append(progs[i].acquire_decimated(soc, progress=True))

# Plot results
fig, axes = plt.subplots(len(iq_lists), 1, figsize=(15, 8))
if len(iq_lists) == 1:
    axes = [axes]

for i, iq_list in enumerate(iq_lists):
    axes[i].set_title(f"Board {i+1}")
    for ii, iq in enumerate(iq_list):
        axes[i].plot(iq[:, 0], label=f"I, ADC {config['ro_chs'][ii]}", alpha=0.7)
        axes[i].plot(iq[:, 1], label=f"Q, ADC {config['ro_chs'][ii]}", alpha=0.7)
        axes[i].plot(np.abs(iq.dot([1, 1j])), label=f"Mag, ADC {config['ro_chs'][ii]}", alpha=0.7)
    axes[i].set_ylabel("Amplitude (a.u.)")
    axes[i].set_xlabel("Sample")
    axes[i].legend()
    axes[i].grid(True)

plt.tight_layout()
plt.show()

3.2 Synchronized Multi-Board Acquisition

Now run the boards together. Adjust leader_delay to align the pulses as desired.

If the boards are well synchronized, the pulses should all lie on top of each other.

[ ]:
# Adjust leader_delay for synchronization
# Experiment with this value to align the pulses
config['leader_delay'] = 14  # tProc ticks

# Create programs
progs = []
for i, (soc, soccfg) in enumerate(proxies):
    config['send_start'] = (i == 0)  # Only leader sends start pulse
    progs.append(LoopbackProgram(soccfg, config))

# Run synchronized acquisition
loop = asyncio.get_event_loop()
iq_lists = loop.run_until_complete(
    run_multi_decimated(loop, proxies, progs, progress=True, rounds=100)
)

# Plot results
fig, axes = plt.subplots(len(progs), 1, figsize=(15, 10))
if len(progs) == 1:
    axes = [axes]

colors = ['blue', 'red']
for i, prog in enumerate(progs):
    axes[i].set_title(['Leader', 'Follower'][i])
    for iq_list in prog.get_rounds():
        for ii, iq in enumerate(iq_list):
            t = 1000 * prog.get_time_axis(ro_index=ii)
            axes[i].plot(t, get_inphase(iq.T),
                        label=f"ADC {config['ro_chs'][ii]}",
                        color=colors[ii], alpha=0.7)
    axes[i].set_ylabel("Amplitude (a.u.)")
    axes[i].set_xlabel("Time (ns)")
    axes[i].legend()
    axes[i].grid(True)

plt.tight_layout()
plt.show()

3.3 Jitter Analysis

Calculate the pulse edge positions and analyze the inter-board jitter.

[ ]:
# Fit pulse edges for all rounds
edges = np.array([[
    [find_edges(get_inphase(iq.T)) for iq in iq_list]
    for iq_list in prog.get_rounds()
] for prog in progs])

# Convert to ns
edges *= progs[0].get_time_axis(ro_index=0)[1] * 1e3

# Use the average of rising and falling edges as the pulse time
pulsetimes = edges[..., :2].mean(axis=-1)

# Plot jitter correlations
fig, axes = plt.subplots(2, 2, figsize=(10, 10))

# Cross-correlation between readouts on same board
for i_soc in range(len(proxies)):
    plot_jitter(axes[0, i_soc],
                pulsetimes[i_soc, :, 0],
                pulsetimes[i_soc, :, 1])
    axes[0, i_soc].set_xlabel(f"ADC {0}, Board {i_soc} (ns)")
    axes[0, i_soc].set_ylabel(f"ADC {1}, Board {i_soc} (ns)")
    axes[0, i_soc].set_title(f"Board {i_soc} - Intra-Board Jitter")

# Cross-correlation between boards for same readout
for i_ro in range(len(RO_CHS)):
    plot_jitter(axes[1, i_ro],
                pulsetimes[0, :, i_ro],
                pulsetimes[1, :, i_ro])
    axes[1, i_ro].set_xlabel(f"ADC {i_ro}, Board 0 (ns)")
    axes[1, i_ro].set_ylabel(f"ADC {i_ro}, Board 1 (ns)")
    axes[1, i_ro].set_title(f"ADC {i_ro} - Inter-Board Jitter")

plt.tight_layout()
plt.show()

# Print jitter statistics
print("\n=== Jitter Analysis ===")
for i_ro in range(len(RO_CHS)):
    diff = pulsetimes[0, :, i_ro] - pulsetimes[1, :, i_ro]
    print(f"ADC {i_ro} inter-board jitter: {np.std(diff):.3f} ns RMS")

4. tProc v2 Example

The same approach works with tProc v2, but you need firmware revision 24 or later.

Important: For jitter-free operation, the tProc dispatcher clock should be an integer multiple of the core clock. Standard firmwares have been updated to meet this requirement.

Recommended firmwares:

[ ]:
# Channel configuration for tProc v2
GEN_CHS = [7, 6]
RO_CHS = [0, 1]
TRIG_TIME = 0.35  # us
FREQ = 500  # MHz

print(f"Generator channels: {GEN_CHS}")
print(f"Readout channels: {RO_CHS}")
print(f"Frequency: {FREQ} MHz")
[ ]:
class LoopbackProgramV2(AveragerProgramV2):
    """
    Loopback program for tProc v2.
    Generates a pulse and measures it on the same board or across boards.
    """
    def _initialize(self, cfg):
        ro_chs = cfg['ro_chs']
        gen_chs = cfg['gen_chs']
        for gen_ch in gen_chs:
            self.declare_gen(ch=gen_ch, nqz=1)
            # Create a custom envelope with ramps
            maxv = self.get_maxv(gen_ch)
            idata = np.ones(10*16, dtype=np.int32) * maxv
            idata[:cfg['ramp_nsamp']] = np.linspace(0, maxv, cfg['ramp_nsamp'], dtype=np.int32)
            idata[:-cfg['ramp_nsamp']-1:-1] = np.linspace(0, maxv, cfg['ramp_nsamp'], dtype=np.int32)
            qdata = np.zeros(10*16, dtype=np.int32)
            self.add_envelope(ch=gen_ch, name="ramp", idata=idata, qdata=qdata)

        self.add_pulse(ch=gen_chs,
                       name="mypulse",
                       ro_ch=ro_chs[0],
                       style="flat_top",
                       envelope="ramp",
                       freq=cfg['freq'],
                       length=cfg['pulse_len'],
                       phase=cfg['gen_phase'],
                       gain=cfg['gen_gain'])

        for ro_ch in ro_chs:
            self.declare_readout(ch=ro_ch, length=cfg['ro_len'])

        self.add_readoutconfig(ch=ro_chs,
                               name="myro",
                               freq=cfg['freq'],
                               gen_ch=gen_chs[0])

        for ro_ch in ro_chs:
            self.send_readoutconfig(ch=ro_ch, name="myro", t=0)

        if cfg['send_start']:
            self.delay(0.5)  # give processor some time to set registers
            self.trigger(pins=[0], t=0)  # output a pulse on PMOD0_0, to trigger the follower
            self.delay(cfg['leader_delay'])  # give follower time to catch up
        if cfg['follower_delay'] > 0:
            self.delay(cfg['follower_delay'])

    def _body(self, cfg):
        self.delay(10)
        for gen_ch in cfg['gen_chs']:
            self.pulse(ch=gen_ch, name="mypulse", t=0)
        self.trigger(ros=cfg['ro_chs'], t=cfg['trig_time'])

# Base configuration for tProc v2
config = {
    'gen_chs': GEN_CHS,
    'ro_chs': RO_CHS,
    'freq': FREQ,
    'trig_time': TRIG_TIME,
    'ro_len': 0.2,
    'pulse_len': 0.05,
    'ramp_nsamp': 32,  # DAC samples
    'gen_gain': 1.0,
    'gen_phase': 0.0,
    'send_start': False,
    'leader_delay': 0.05,  # us
    'follower_delay': 0,  # us
}

print("tProc v2 loopback program defined.")
[ ]:
# Test each board individually with tProc v2
progs = []
config['send_start'] = False

for i, (soc, soccfg) in enumerate(proxies):
    progs.append(LoopbackProgramV2(soccfg, reps=1, final_delay=0.5, cfg=config))

iq_lists = []
for i, (soc, soccfg) in enumerate(proxies):
    print(f"Acquiring data from board {i+1} (tProc v2)...")
    iq_lists.append(progs[i].acquire_decimated(soc, progress=True, rounds=100))

# Plot results
fig, axes = plt.subplots(len(iq_lists), 1, figsize=(15, 8))
if len(iq_lists) == 1:
    axes = [axes]

for i, iq_list in enumerate(iq_lists):
    axes[i].set_title(f"Board {i+1} (tProc v2)")
    for ii, iq in enumerate(iq_list):
        axes[i].plot(iq[:, 0], label=f"I, ADC {config['ro_chs'][ii]}", alpha=0.7)
        axes[i].plot(iq[:, 1], label=f"Q, ADC {config['ro_chs'][ii]}", alpha=0.7)
        axes[i].plot(np.abs(iq.dot([1, 1j])), label=f"Mag, ADC {config['ro_chs'][ii]}", alpha=0.7)
    axes[i].set_ylabel("Amplitude (a.u.)")
    axes[i].set_xlabel("Sample")
    axes[i].legend()
    axes[i].grid(True)

plt.tight_layout()
plt.show()
[ ]:
# Synchronized acquisition with tProc v2
# Adjust leader_delay for alignment (use cycles2us conversion)
config['leader_delay'] = soccfg.cycles2us(22)  # Convert tProc cycles to us

progs = []
for i, (soc, soccfg) in enumerate(proxies):
    config['send_start'] = (i == 0)  # Only leader sends start pulse
    progs.append(LoopbackProgramV2(soccfg, reps=1, final_delay=0.5, cfg=config))

# Run synchronized acquisition
loop = asyncio.get_event_loop()
iq_lists = loop.run_until_complete(
    run_multi_decimated(loop, proxies, progs, rounds=100)
)

# Plot results
fig, axes = plt.subplots(len(progs), 1, figsize=(15, 10))
if len(progs) == 1:
    axes = [axes]

colors = ['blue', 'red']
for i, prog in enumerate(progs):
    axes[i].set_title(['Leader', 'Follower'][i])
    for iq_list in prog.get_rounds():
        for ii, iq in enumerate(iq_list):
            t = 1000 * prog.get_time_axis(ro_index=ii)
            axes[i].plot(t, get_inphase(iq),
                        label=f"ADC {config['ro_chs'][ii]}",
                        color=colors[ii], alpha=0.7)
    axes[i].set_ylabel("Amplitude (a.u.)")
    axes[i].set_xlabel("Time (ns)")
    axes[i].legend()
    axes[i].grid(True)

plt.tight_layout()
plt.show()
[ ]:
# Jitter analysis for tProc v2
edges = np.array([[
    [find_edges(get_inphase(iq)) for iq in iq_list]
    for iq_list in prog.get_rounds()
] for prog in progs])

# Convert to ns
edges *= progs[0].get_time_axis(ro_index=0)[1] * 1e3

# Pulse times from average of rising and falling edges
pulsetimes = edges[..., :2].mean(axis=-1)

# Plot jitter correlations
fig, axes = plt.subplots(2, 2, figsize=(10, 10))

for i_soc in range(len(proxies)):
    plot_jitter(axes[0, i_soc],
                pulsetimes[i_soc, :, 0],
                pulsetimes[i_soc, :, 1])
    axes[0, i_soc].set_xlabel(f"ADC {0}, Board {i_soc} (ns)")
    axes[0, i_soc].set_ylabel(f"ADC {1}, Board {i_soc} (ns)")
    axes[0, i_soc].set_title(f"Board {i_soc} (tProc v2) - Intra-Board Jitter")

for i_ro in range(len(RO_CHS)):
    plot_jitter(axes[1, i_ro],
                pulsetimes[0, :, i_ro],
                pulsetimes[1, :, i_ro])
    axes[1, i_ro].set_xlabel(f"ADC {i_ro}, Board 0 (ns)")
    axes[1, i_ro].set_ylabel(f"ADC {i_ro}, Board 1 (ns)")
    axes[1, i_ro].set_title(f"ADC {i_ro} (tProc v2) - Inter-Board Jitter")

plt.tight_layout()
plt.show()

# Print jitter statistics
print("\n=== Jitter Analysis (tProc v2) ===")
for i_ro in range(len(RO_CHS)):
    diff = pulsetimes[0, :, i_ro] - pulsetimes[1, :, i_ro]
    print(f"ADC {i_ro} inter-board jitter: {np.std(diff):.3f} ns RMS")

5. Summary

You have learned:

  1. External Clock Synchronization: Locking multiple boards to a common reference clock

  2. External Start Signaling: Using PMOD pins to synchronize tProc start

  3. Leader-Follower Architecture: One board triggers others via external start

  4. Software Synchronization: Coordinating multiple programs with async/await

  5. Jitter Measurement: Quantifying inter-board timing accuracy

Key Takeaways

  • tProc v1 external start works reliably; tProc v2 requires revision 24+

  • The phase relationship between tProc clocks is random; re-initialize if you see one-tick jitter

  • For multi-follower setups, wire all followers’ external start pins to the leader’s output

  • The jitter is typically below the measurement resolution of this technique (limited by decimated data interpolation)

  • For better jitter measurements, use a scope or the time-tagger readout

Next Steps

  • Proceed to `11_Streaming_And_RealTime_Processing.ipynb <./11_Streaming_And_RealTime_Processing.ipynb>`__

  • For more advanced multi-board synchronization, see the XCOM network documentation