Source code for FIREQ_SYSTEM.signal_generator_node

"""Signal Generator Node class for FIREQ system node representation."""

from __future__ import annotations

import logging
from typing import Any

import numpy as np

from FIREQ_LL_API import GeneratorDriver

from ._generic_node import _GenericNode
from ._utils import _get_periods_from_clock

logger = logging.getLogger(__name__)


class _GenericEnvelope(_GenericNode):
    """Object representing a pulse envelope.

    Configuration dictionary keys:

    - ``_name`` (str): Name of the envelope (``_RECTANGULAR`` is a protected keyword).
    - ``_for_interpolation`` (bool): Uses hardware interpolation. Defaults to ``False``.
    - ``_is_symmetric`` (bool): Only specify in case of interpolation. Defaults to ``False``.
    - ``_i_even`` (bool): Only specify if symmetric.
    - ``_q_even`` (bool): Only specify if symmetric.
    - ``$samples`` (list[complex]): List of complex sample values.
    """

    nodetype = "envelope"

    def __init__(
        self,
        name: str,
        parent: SignalGeneratorNode,
        _for_interpolation: bool = False,
        _is_symmetric: bool = False,
        _i_even: bool | None = None,
        _q_even: bool | None = None,
    ) -> None:
        """Initialize the envelope item.

        :param name: Name of the envelope
        :type name: str
        :param parent: Parent signal generator node
        :type parent: SignalGeneratorNode
        :param _for_interpolation: If the envelope is for interpolation, defaults to ``False``
        :type _for_interpolation: bool
        :param _is_symmetric: If the envelope is symmetric, defaults to ``False``;
            ignored if interpolation is disabled
        :type _is_symmetric: bool
        :param _i_even: If the envelope is even for I, defaults to ``None``
        :type _i_even: bool or None
        :param _q_even: If the envelope is even for Q, defaults to ``None``
        :type _q_even: bool or None
        :raises ValueError: If ``_i_even`` and/or ``_q_even`` are not specified
            when both interpolation and symmetry are enabled
        """
        super().__init__(name, parent)
        # validation of arguments
        self._for_interpolation = _for_interpolation
        self._is_symmetric = _is_symmetric
        self._i_even = _i_even
        self._q_even = _q_even
        # check inputs in case of interpolation
        if self._for_interpolation and self._is_symmetric:
            if self._i_even is None or self._q_even is None:
                self.log.error("i_even and/or q_even not specified")
                raise ValueError("i_even and/or q_even not specified")
        self.natural_length: int | None = None
        self._address: int = 0
        # create the envelope part of the wdw
        self.envelope_wdw = self.parent._ll_handler.build_envelope_specific_wdw(
            is_symmetric=self._is_symmetric,
            i_even=self._i_even if self._i_even is not None else False,
            q_even=self._q_even if self._q_even is not None else False,
            forceone=False,
            for_interpolation=self._for_interpolation,
        )

    @_GenericNode.parameter_callback("$samples", sweepable=False, cost=1000)
    def write_samples(self, samples: np.ndarray) -> int:
        """Write the envelope samples to generator memory.

        :param samples: Array of complex samples of the envelope, normalized to 1
            (values between -1 and 1)
        :type samples: np.ndarray
        :return: Error code (0 on success)
        :rtype: int
        """
        div = 1 if self._for_interpolation else self.parent._ll_handler.number_of_channels
        memory_depth = len(samples) // div
        self._address = self.parent.reserve_envelope_segment(memory_depth)
        self.natural_length = len(samples)
        return self.parent._ll_handler.write_envelope_memory(
            start_address=self._address, envelope=samples, common=self._for_interpolation
        )


class _RectangularEnvelope(_GenericNode):
    """Object representing a rectangular envelope.

    Reserved for rectangular shaped pulses; only created once at initialization time
    with the reserved name ``_RECTANGULAR``.
    """

    nodetype = "envelope"

    def __init__(self, name: str, parent: SignalGeneratorNode) -> None:
        """Initialize the rectangular envelope.

        :param name: Name of the envelope (should be ``_RECTANGULAR``)
        :type name: str
        :param parent: Parent signal generator node
        :type parent: SignalGeneratorNode
        """
        super().__init__(name, parent)
        self.natural_length = 1
        self._address = 0
        self.envelope_wdw = self.parent._ll_handler.build_envelope_specific_wdw(
            is_symmetric=False, i_even=False, q_even=False, forceone=True, for_interpolation=False
        )


class _VZGate(_GenericNode):
    """Object representing a virtual Z rotation gate.

    Configuration dictionary keys:

    - ``_name`` (str): Name of the gate.
    - ``_readout`` (bool): If set, the pulse is a readout pulse. Defaults to ``False``.
    - ``$vz_rotation`` (float): Phase of the VZ rotation, normalized to 2π.
    """

    nodetype = "vzgate"

    def __init__(self, name: str, parent: SignalGeneratorNode, _readout: bool = False) -> None:
        """Initialize the virtual Z gate.

        :param name: Name of the gate
        :type name: str
        :param parent: Parent signal generator node
        :type parent: SignalGeneratorNode
        :param _readout: Whether this is a readout pulse, defaults to ``False``
        :type _readout: bool
        """
        super().__init__(name, parent)
        self._readout = _readout
        self._address: int | None = None
        if not self._readout:
            self._address = self.parent.reserve_wdw()

    @_GenericNode.parameter_callback("$vz_rotation", sweepable=True, cost=10)
    def write_pulse(self, normalized_phase: float) -> int:
        """Write the wave definition word to memory with the specified phase.

        :param normalized_phase: Phase of VZ rotation normalized to 2π
        :type normalized_phase: float
        :return: Error code (0 on success)
        :rtype: int
        """
        wdw = self.parent._ll_handler.build_vz_wdw(normalized_phase)
        if self._readout:
            return self.parent._ll_handler.write_readout_wave(wdw)
        else:
            return self.parent._ll_handler.add_wave_in_wave_memory(wdw, self._address)


class _Pulse(_GenericNode):
    """Object representing a pulse (wave definition word).

    Configuration dictionary keys:

    - ``_name`` (str): Name of the pulse (gate).
    - ``_readout`` (bool): If set, the pulse is a readout pulse. Defaults to ``False``.
    - ``_envelope`` (str): Name of the envelope to use for the pulse.
    - ``_switch_iq`` (bool): If set, I and Q values are switched. Defaults to ``False``.
    - ``_keep_last`` (bool): If set, the last sample will be placed at the output. Defaults to ``False``.
    - ``$duration`` (float): Duration of the pulse, in nanoseconds.
    - ``$gain`` (float): Gain, between -1 and 1.
    """

    nodetype = "pulse"

    def __init__(
        self,
        name: str,
        parent: SignalGeneratorNode,
        _readout: bool = False,
        _envelope: str | None = None,
        _switch_iq: bool = False,
        _keep_last: bool = False,
        _dac_target: int = 1,
    ) -> None:
        """Initialize the pulse.

        The ''_dac_target'' is used in combination with the crossbar, to send the pulse to a certain set of dacs.

        :param name: Name of the pulse
        :type name: str
        :param parent: Parent signal generator node
        :type parent: SignalGeneratorNode
        :param _readout: Whether this is a readout pulse, defaults to ``False``
        :type _readout: bool
        :param _envelope: Name of the envelope to use for the pulse
        :type _envelope: str or None
        :param _switch_iq: Whether to switch I and Q, defaults to ``False``
        :type _switch_iq: bool
        :param _keep_last: Whether to keep the last sample, defaults to ``False``
        :type _keep_last: bool
        :param _dac_target: Mask, sets where the pulse is sent, defaults to 1
        :type _dac_target: int
        :raises ValueError: If the envelope name is ``None`` or not found
        """
        super().__init__(name, parent)
        self._readout = _readout
        self._envelope = _envelope
        self._switch_iq = _switch_iq
        self._keep_last = _keep_last
        self._dac_target = _dac_target
        if _envelope is None:
            self.log.error("envelope not specified")
            raise ValueError("envelope not specified")
        # check if envelope exists and get the reference to use it later
        envelope_children = [child for child in self.parent.children if child.nodetype == "envelope"]
        if self._envelope not in [child.name for child in envelope_children]:
            self.log.error("envelope %s not found", self._envelope)
            raise ValueError(f"envelope {self._envelope} not found")
        self._envelope_ref = next(child for child in envelope_children if child.name == self._envelope)
        if not self._readout:
            self._address = self.parent.reserve_wdw()
        else:
            self._address = None
        # starting values for duration and gain
        self._wanted_duration = self._envelope_ref.natural_length
        self._wanted_gain = 0.0

    @_GenericNode.parameter_callback("$duration", sweepable=True, cost=10)
    def set_duration(self, duration: float) -> int:
        """Set the pulse duration.

        :param duration: Duration in nanoseconds
        :type duration: float
        :return: Error code (0 on success)
        :rtype: int
        """
        self._wanted_duration = _get_periods_from_clock(duration, self.parent._sampling_frequency)
        return self._write_pulse()

    @_GenericNode.parameter_callback("$gain", sweepable=True, cost=10)
    def set_gain(self, value: float) -> int:
        """Set the pulse gain.

        :param value: Gain value between -1 and 1
        :type value: float
        :return: Error code (0 on success)
        :rtype: int
        """
        self._wanted_gain = value
        return self._write_pulse()

    def _write_pulse(self) -> int:
        """Write the wave definition word to memory.

        :return: Error code (0 on success)
        :rtype: int
        """
        # build the wdw
        wdw = self.parent._ll_handler.build_pulse_wdw(
            envelope_wdw=self._envelope_ref.envelope_wdw,
            start_address=self._envelope_ref._address,
            duration=self._wanted_duration,
            natural_duration=self._envelope_ref.natural_length,
            normalized_gain=self._wanted_gain,
            switch_iq=self._switch_iq,
            keep_last=self._keep_last,
            dac_target_mask=self._dac_target,
        )
        if self._readout:
            return self.parent._ll_handler.write_readout_wave(wdw)
        else:
            return self.parent._ll_handler.add_wave_in_wave_memory(wdw, self._address)


[docs] class SignalGeneratorNode(_GenericNode): """Object representing the signal generator system. Configuration dictionary keys: - ``_name`` (str): Name of the signal generator node instance. - ``_ll_handler`` (GeneratorDriver): Low-level handler for the signal generator. - ``$dfrequency`` (float): Drive frequency, in MHz. - ``$rfrequency`` (float): Readout frequency, in MHz. - ``$rphase`` (float): Readout phase, in radians. - ``$rchannel`` (int): Readout trigger channel. Set to ``0`` to deactivate. - ``$dchannel`` (int): Drive trigger channel. Set to ``0`` to deactivate. - ``$lfsr_seed`` (int): Seed for the LFSR. - ``$drive_order`` (list[str]): Ordered list of pulse names to be generated. """ nodetype = "signal_generation" wraps = [GeneratorDriver.__name__] def __init__( self, name: str, parent: _GenericNode, _ll_handler: GeneratorDriver, ) -> None: """Initialize the signal generator node. :param name: Name of the signal generator node :type name: str :param parent: Parent node in the system tree :type parent: _GenericNode :param _ll_handler: Low-level handler for the signal generator :type _ll_handler: GeneratorDriver """ super().__init__(name, parent) self._clock_frequency = self.root.get_fabric_frequency() self._sampling_frequency = self.root.get_generation_sampling_frequency() self._ll_handler = _ll_handler # envelope and wdw memory caching self.init_memory() # create the rectangular envelope _RectangularEnvelope(name="_RECTANGULAR", parent=self)
[docs] def init_memory(self) -> int: """Initialize the memory of the signal generator. :return: Error code (0 on success) :rtype: int """ self._envelope_next_address: int = 0 self._wdw_next_address: int = 0 return self._ll_handler.clear_envelope_memory()
def _reset_all(self) -> None: for child in self.children: child.parent = None # recreate the rect envelope _RectangularEnvelope(name="_RECTANGULAR", parent=self) # envelope and wdw memory caching self.init_memory()
[docs] def reserve_envelope_segment(self, sample_depth: int) -> int: """Reserve a segment of the envelope memory. This function does not check if the segment is actually valid; it always returns an address and advances the internal pointer. :param sample_depth: Length of the segment in samples :type sample_depth: int :return: The starting address of the reserved segment :rtype: int """ address = self._envelope_next_address self._envelope_next_address += sample_depth return address
[docs] def reserve_wdw(self) -> int: """Reserve a wave definition word slot in memory. :return: The index of the reserved WDW slot :rtype: int """ address = self._wdw_next_address self._wdw_next_address += 1 return address
[docs] @_GenericNode.parameter_callback("$dfrequency", sweepable=True, cost=1) def set_drive_frequency(self, frequency: float) -> int: """Set the drive frequency. :param frequency: Frequency in MHz :type frequency: float :return: Error code (0 on success) :rtype: int """ return self._ll_handler.set_drive_modulation_frequency(frequency / self._sampling_frequency)
[docs] @_GenericNode.parameter_callback("$rfrequency", sweepable=True, cost=1) def set_readout_frequency(self, frequency: float) -> int: """Set the readout frequency. :param frequency: Frequency in MHz :type frequency: float :return: Error code (0 on success) :rtype: int """ return self._ll_handler.set_readout_modulation_frequency(frequency / self._sampling_frequency)
[docs] @_GenericNode.parameter_callback("$rphase", sweepable=True, cost=1) def set_readout_phase(self, phase: float) -> int: """Set the readout phase. :param phase: Phase in radians :type phase: float :return: Error code (0 on success) :rtype: int """ return self._ll_handler.set_readout_modulation_initial_phase(phase / (2 * np.pi))
[docs] @_GenericNode.parameter_callback("$rchannel", sweepable=False, cost=1) def set_readout_channel(self, channel: int) -> int: """Set the readout trigger channel. :param channel: Channel number, set to 0 to deactivate :type channel: int :return: Error code (0 on success) :rtype: int """ return self._ll_handler.set_trigger_channel(channel, "readout")
[docs] @_GenericNode.parameter_callback("$dchannel", sweepable=False, cost=1) def set_drive_channel(self, channel: int) -> int: """Set the drive trigger channel. :param channel: Channel number, set to 0 to deactivate :type channel: int :return: Error code (0 on success) :rtype: int """ return self._ll_handler.set_trigger_channel(channel, "drive")
[docs] @_GenericNode.parameter_callback("$lfsr_seed", sweepable=True, cost=1) def set_lfsr_seed(self, seed: int) -> int: """Set the LFSR seed. :param seed: Seed for the LFSR :type seed: int :return: Error code (0 on success) :rtype: int """ return self._ll_handler.set_lfsr_seed(seed)
[docs] @_GenericNode.parameter_callback("$drive_order", sweepable=False, cost=1) def set_drive_order(self, order: list[str]) -> int: """Set the order of drive generation. :param order: Ordered list of pulse names to be generated :type order: list[str] :return: Error code (0 on success) :rtype: int """ # for each element in the list, search the children for the matching # pulse or vzgate and build a list of addresses addresses: list[int] = [] for pulse_name in order: pulse = next( ( child for child in self.children if child.name == pulse_name and child.nodetype in ("pulse", "vzgate") ), None, ) if pulse is None: self.log.error("pulse %s not found in children", pulse_name) return -3 if pulse._readout: self.log.error( "pulse %s is a readout pulse and cannot be placed in the drive order", pulse_name, ) return -3 addresses.append(pulse._address) # write the addresses to the memory mapped FIFO for order_index, wdw_index in enumerate(addresses): ret = self._ll_handler.add_wave_to_drive_wave_sequence(order_index, wdw_index) if ret != 0: self.log.error("failed to write drive order at index %s", order_index) return ret self.log.debug("drive order set to %s", order) return 0
[docs] @_GenericNode.parameter_callback("$tmanual_dest", sweepable=False, cost=1) def set_manual_wave_destination(self, destination: str) -> int: """Set the destination where manual waves should be sent. :param destination: 'readout' or 'drive' :type destination: str :return: Error code (0 on success) :rtype: int """ return self._ll_handler.set_manual_wave_destination(destination=destination)
[docs] def manual_trigger(self) -> int: """Trigger the generator manually.""" return self._ll_handler.trigger_manually()
[docs] def create_child(self, name: str, of_type: str, **kwargs: dict[str, Any]) -> _GenericEnvelope | _Pulse | _VZGate: """Create a child node of the specified type. :param name: Name of the child node :type name: str :param of_type: Type of child node — ``"envelope"``, ``"pulse"`` or ``"vzgate"`` :type of_type: str :param kwargs: Additional arguments to pass to the child node :type kwargs: dict[str, Any] :return: The created child node :rtype: _GenericEnvelope or _Pulse or _VZGate :raises ValueError: If the name is already taken or the type is unsupported """ # check that the name is not already taken by an existing child if any(child.name == name for child in self.children): self.log.error("child with name %s already exists", name) raise ValueError(f"child with name {name} already exists") if of_type == "envelope": return _GenericEnvelope(name=name, parent=self, **kwargs) elif of_type == "pulse": return _Pulse(name=name, parent=self, **kwargs) elif of_type == "vzgate": return _VZGate(name=name, parent=self, **kwargs) else: self.log.error("unsupported child type %s", of_type) raise ValueError(f"unsupported child type {of_type}")