Hello!

I'm looking at the phase alignment of Phaser (single tone) and Urukul (AD9910) using the code below. The goal is to achieve deterministic absolute phase between a Urukul and a Phaser channel, as advertised by the Phaser docstring (i.e. for commensurate frequencies, the phases of the Urukul and Phaser outputs are equal w.r.t. e.g. a trigger edge).

Aligning the phase accumulator manipulation to the fastlink frame cycles as mentioned in this thread provides stable phase of both RF channels with respect to the trigger. However, their difference doesn't vanish (and the value obviously depends on the selected output frequency).

My understanding is that one would need to compensate for:

  • the delay in applying the phase calculated in ad9910.set_mu(). Part of it is known (write64), but how far is "as far away" from the SYNC_CLK for the IO_UPDATE alignment?
  • the constant update delay in the Phaser signal chain.

Is this correct? If so, can the unknown values be calculated, to avoid calibration? If not, is there a conceptual mistake in the code below?

Thanks in advance!

from artiq.experiment import *
from artiq.coredevice import ad9910
from artiq.coredevice.rtio import rtio_output, rtio_input_timestamp
from artiq.coredevice.core import rtio_get_counter

from numpy import int32, int64


class PhaserUrukulPhase(EnvExperiment):
    def build(self):
        self.setattr_device("core")
        self.phaser = self.get_device("phaser0")
        self.dds = self.get_device("urukul0_ch0")
        self.trg = self.get_device("ttl4")

        self.frame_tstamp = int64(0)

    @kernel
    def run(self):
        self.init()

        ch = self.phaser.channel[0]

        ch.set_duc_frequency(100 * MHz)
        ch.set_duc_cfg(clr_once=1)
        ch.set_att(3 * dB)
        ch.oscillator[0].set_frequency(2 * MHz)

        # align the phase origin to the fastlink frames
        n = int64((now_mu() - self.frame_tstamp) / self.phaser.t_frame)
        t_ref = self.frame_tstamp + (n + 1) * self.phaser.t_frame

        at_mu(t_ref)
        with parallel:
            # setup DUC, clear DUC phase
            self.phaser.duc_stb()
            # reset Kasli oscillator phase
            ch.oscillator[0].set_amplitude_phase(amplitude=0.0, phase=0.0, clr=1)

        # enable Kasli oscillator output
        ch.oscillator[0].set_amplitude_phase(amplitude=0.28)

        # setup AD9910 output with phase origin at t_ref
        self.dds.set(
            102 * MHz,
            phase_mode=ad9910.PHASE_MODE_TRACKING,
            ref_time_mu=t_ref,
        )

        self.trg.pulse_mu(500)
        self.dds.sw.on()

    @kernel
    def init(self):
        self.core.reset()
        self.trg.output()

        # phaser init
        self.phaser.init()
        # determine fastlink frame alignment
        # (see get_frame_alignment() in stft_pulsegen branch
        # of github.com/quartiq/phaser)
        rtio_output(self.phaser.channel_base << 8, 0)
        delay_mu(int64(self.phaser.t_frame))
        self.frame_tstamp = rtio_input_timestamp(
            rtio_get_counter() + 0xFFFFFF, self.phaser.channel_base
        )
        delay(10 * ms)

        # urukul channel init
        self.dds.cpld.init()
        self.dds.init()
        self.dds.set_att_mu(220)
        self.dds.sw.off()

        self.core.break_realtime()

The underlying reason we don't attempt to match latencies is the uncertainty about a useful choice of reference plane. In experiments the typical reference plane is at the qubit. That would include all latencies of cables, amplifiers, AOMs etc and is strictly external to ARTIQ/Sinara. Therefore we generally only make the latency deterministic but not matched.

Other than that I think your approach is correct.

To have the gateware compensate latencies for you, you may want to try setting a latency compensation for the different RTIO channels, like done in SAWG. But this may prove to be tricky due to several factors (input-output channel matching, slack changing effect...). These challenges are also inherent to any attempt at latency matching whether it's hardware, gateware, or experiment-level.

Computing the total latency on the digital side is certainly possible, but requires quite some book-keeping and detailed look at every layer. My guess is that calibration is easier and then would also include analog latencies.
Be careful to always distinguish group delay from phase delay in these exercises.

And regarding the IO_UPDATE tuning: do it only once. Use the (fixed) result from then on. The value of "as far away as possible" is 2 fine RTIO periods (2 SYSCLK cycles). Since set_mu always aligns to coarse RTIO period, and since the SYNC_IN generator is also aligned to coarse RTIO, the actual value of the IO_UPDATE delay should not enter your computation.

Thanks a lot for the feedback and the insight! The full calculation on the digital side indeed looks way too tedious but I was wondering about the steps I missed.

You surely have a good point about the extra analog latencies. One point in favor of matched latencies is HITL testing at the crate/channel level, but with fixed latency this is manageable.

a month later

@rjo @dpn As a follow-up to the previous question, and assuming that latencies are matched on the hardware side (not the case but for the sake of the argument): looking at the formula given in the AD9910.set_mu() docstring for coherent phase updates, I'd assume that the following experiment produces a sine wave with phase 0 at the time of the trigger.

This is however not the case: the phase offset is stable between repetitions of the experiment, but depends on the frequency. Compensating the reference time (but not the trigger time) by 1248 + 2 mu (write64 + io_update/sync_clk alignment) doesn't lead to better results.

Is it conceptually wrong? expected not to work?

from artiq.experiment import *
from artiq.coredevice import ad9910

class PhaseTracking(EnvExperiment):
    def build(self):
        self.setattr_device("core")
        self.dds0 = self.get_device("urukul0_ch0")
        self.trg = self.get_device("ttl4")

        self.ftw = self.dds0.frequency_to_ftw(145.0 * MHz)

    @kernel
    def run(self):
        self.core.reset()

        self.dds0.cpld.init()
        self.dds0.init()
        self.dds0.set_att(6 * dB)

        delay(100 * ms)

        at_mu(now_mu() & ~7)
        t_ref = now_mu() + 8_000  # enough slack for set_mu()

        self.dds0.set_mu(
            self.ftw,
            phase_mode=ad9910.PHASE_MODE_TRACKING,
            ref_time_mu=t_ref,
        )
        self.dds0.sw.on()

        at_mu(t_ref)
        self.trg.on()

        delay_mu(100_000)
        self.trg.off()
        self.dds0.sw.off()

A deterministic latency mismatch of t will lead to a frequency-dependent phase of f*t cycles at the trigger rising edge. The latency mismatch should be (but note that I didn't look at the code) the io_update alignment delay plus DDS internal digital latency plus various analog latencies (lvds level converter, cpld, amplifier, balun, switches vs ttl lvds converts, couplers, and drivers). Also note that there may be an amplitude sign change in there (frequency-independent phase offset of half a cycle due to balun/amplifier sign changes). I'm unsure about the role of the DDS f/a/p latency match bit in CFR2 and whether we set that. Also unsure about whether the code includes the write64 latency.
Measure the latency mismatch either based on the phase shift using a lower frequency first (1 MHz) so the mismatch is unambiguously less than one cycle and then measuring the phase between ttl and dds the way you do it here. Or measure it by looking at the group delay.

Also note that there are a bunch of analog elements (filters, baluns, couplers, amplifiers) in there that will introduce a phase shift that is not linear in frequency (on top of the affine phase shift you can calibrate away) which will complicate your endeavour. Severity depends on the level of accuracy and bandwidth you need.
(That's the beauty of linear phase digital elements).

Measure the latency mismatch either based on the phase shift using a lower frequency first (1 MHz) so the mismatch is unambiguously less than one cycle and then measuring the phase between ttl and dds the way you do it here.

Do it for two different frequencies to get both the phase offset and the delay.

Or measure it by looking at the group delay.

The latter assumes linear (affine) phase which is generally not given over a wide bandwidth

a month later

@airwoodix If you go down any of those routes it would be great to hear back so that others can benefit from the data and insight.

Indeed, sorry for the absence of feedback. I didn't go through the full characterization, only measured the io_update to output delay on Urukul/AD9910 (with CFR2[7] - matched latency enabled) which turns out to be around 90 ns for our crates. This was sufficient to be able to set the absolute phase at a given point in time (i.e. in phase tracking mode, the phase at ref_time_mu is really 0, not some arbitrary but common value between all the channels). I'll try to post some data here later.