Source code for qblox_scheduler.compilation

# Repository: https://gitlab.com/qblox/packages/software/qblox-scheduler
# Licensed according to the LICENSE file on the main branch
#
# Copyright 2020-2025, Quantify Consortium
# Copyright 2025, Qblox B.V.
"""Compiler for the qblox_scheduler."""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, overload

import networkx as nx

from qblox_scheduler.backends.circuit_to_device import extract_clocks_used
from qblox_scheduler.enums import BinMode, SchedulingStrategy
from qblox_scheduler.json_utils import load_json_schema, validate_json
from qblox_scheduler.operations.control_flow_library import (
    ControlFlowOperation,
    LoopOperation,
    LoopStrategy,
)
from qblox_scheduler.operations.expressions import (
    BinaryExpression,
    DType,
    Expression,
    UnaryExpression,
)
from qblox_scheduler.operations.hardware_operations.inline_q1asm import InlineQ1ASM
from qblox_scheduler.operations.loop_domains import LinearDomain, arange, unpack_complex_variables
from qblox_scheduler.operations.operation import Operation
from qblox_scheduler.operations.pulse_compensation_library import PulseCompensation
from qblox_scheduler.operations.pulse_library import IdlePulse, ResetClockPhase
from qblox_scheduler.operations.variables import Variable
from qblox_scheduler.resources import BasebandClockResource, DigitalClockResource
from qblox_scheduler.schedules.schedule import (
    Schedulable,
    TimeableSchedule,
    TimeableScheduleBase,
    TimingConstraint,
)

if TYPE_CHECKING:
    from qblox_scheduler.backends.graph_compilation import CompilationConfig

[docs] logger = logging.getLogger(__name__)
[docs] def _shift_timing(schedule: TimeableSchedule, delta: float) -> None: if schedule.schedulables: start_schedulable = next(iter(schedule.schedulables.values())) _shift_timing_from(schedule, start_schedulable, delta)
[docs] def _shift_timing_from(schedule: TimeableSchedule, schedulable: Schedulable, delta: float) -> None: shifting = False for curr_schedulable in schedule.schedulables.values(): if schedulable == curr_schedulable: shifting = True if shifting: operation_id = curr_schedulable.data["operation_id"] operation = schedule.operations[operation_id] _shift_timing_at(curr_schedulable, operation, delta)
[docs] def _shift_timing_at( schedulable: Schedulable, operation: Operation | TimeableSchedule, delta: float ) -> None: schedulable.data["abs_time"] += delta if isinstance(operation, TimeableSchedule): _shift_timing(operation, delta) elif isinstance(operation, ControlFlowOperation): # noqa: SIM102 if isinstance(operation.body, TimeableSchedule): _shift_timing(operation.body, delta)
@overload
[docs] def _determine_absolute_timing( schedule: TimeableSchedule, time_unit: Literal["physical", "ideal", None] = "physical", config: CompilationConfig | None = None, ) -> TimeableSchedule: ...
@overload def _determine_absolute_timing( schedule: Operation, time_unit: Literal["physical", "ideal", None] = "physical", config: CompilationConfig | None = None, ) -> Operation | TimeableSchedule: ... def _determine_absolute_timing( schedule: Operation | TimeableSchedule, time_unit: Literal[ "physical", "ideal", None ] = "physical", # should be included in CompilationConfig config: CompilationConfig | None = None, ): """ Determine the absolute timing of a schedule based on the timing constraints. This function determines absolute timings for every operation in the :attr:`~.TimeableScheduleBase.schedulables`. It does this by: 1. iterating over all and elements in the :attr:`~.TimeableScheduleBase.schedulables`. 2. determining the absolute time of the reference operation - reference point :code:`"ref_pt"` of the reference operation defaults to :code:`"end"` in case it is not set (i.e., is :code:`None`). 3. determining the start of the operation based on the :code:`rel_time` and :code:`duration` of operations - reference point :code:`"ref_pt_new"` of the added operation defaults to :code:`"start"` in case it is not set. Parameters ---------- schedule The schedule for which to determine timings. config Compilation config for :class:`~qblox_scheduler.backends.graph_compilation.ScheduleCompiler`. time_unit Whether to use physical units to determine the absolute time or ideal time. When :code:`time_unit == "physical"` the duration attribute is used. When :code:`time_unit == "ideal"` the duration attribute is ignored and treated as if it is :code:`1`. When :code:`time_unit == None` it will revert to :code:`"physical"`. Returns ------- : The modified `.TimeableSchedule`` where the absolute time for each operation has been determined. Raises ------ NotImplementedError If the scheduling strategy is not SchedulingStrategy.ASAP """ time_unit = time_unit or "physical" if time_unit not in (valid_time_units := ("physical", "ideal")): raise ValueError(f"Undefined time_unit '{time_unit}'! Must be one of {valid_time_units}") if isinstance(schedule, TimeableScheduleBase): return _determine_absolute_timing_schedule(schedule, time_unit, config) elif isinstance(schedule, ControlFlowOperation): schedule.body = _determine_absolute_timing(schedule.body, time_unit, config) return schedule elif schedule.duration is None: raise RuntimeError( f"Cannot determine timing for operation {schedule.name}. Operation data: {schedule!r}" ) else: return schedule
[docs] def _determine_absolute_timing_schedule( schedule: TimeableSchedule, time_unit: Literal["physical", "ideal", None], config: CompilationConfig | None, ) -> TimeableSchedule: scheduling_strategy = _determine_scheduling_strategy(config) if not schedule.schedulables: raise ValueError(f"schedule '{schedule.name}' contains no schedulables.") for op_key in schedule.operations: if isinstance(schedule.operations[op_key], TimeableSchedule): if schedule.operations[op_key].get("duration", None) is None: schedule.operations[op_key] = _determine_absolute_timing( schedule=schedule.operations[op_key], time_unit=time_unit, config=config, ) elif isinstance(schedule.operations[op_key], ControlFlowOperation): schedule.operations[op_key] = _determine_absolute_timing( schedule=schedule.operations[op_key], time_unit=time_unit, config=config, ) # Note: type checker can not seem to infer the Operation fields elif isinstance(schedule.operations[op_key], Operation) and ( time_unit == "physical" and not schedule.operations[op_key].valid_pulse # type: ignore and not schedule.operations[op_key].valid_acquisition # type: ignore and not schedule.operations[op_key].valid_statement # type: ignore # TODO (SE-650): move to qblox backend. and not isinstance(schedule.operations[op_key], InlineQ1ASM) ): # Gates do not have a defined duration, so only ideal timing is defined raise RuntimeError( f"Operation {schedule.operations[op_key].name} is not a valid pulse or acquisition." f" Please check whether the device compilation has been performed." f" Operation data: {schedule.operations[op_key]!r}" ) _make_timing_constraints_explicit(schedule, scheduling_strategy) references_graph = _populate_references_graph(schedule) _validate_schedulable_references(schedule, references_graph) schedulables_sorted_by_reference = nx.topological_sort(references_graph) for i, schedulable_name in enumerate(schedulables_sorted_by_reference): i: int schedulable_name: str schedulable: Schedulable = schedule.schedulables[schedulable_name] timing_constraints: list[TimingConstraint] = schedulable.data["timing_constraints"] operation: Operation | TimeableSchedule = schedule.operations[ schedulable.data["operation_id"] ] if scheduling_strategy == SchedulingStrategy.ASAP: for timing_constraint in timing_constraints: abs_time = _get_start_time(schedule, timing_constraint, operation, time_unit) if "abs_time" not in schedulable or abs_time > schedulable["abs_time"]: schedulable.data["abs_time"] = abs_time else: schedulable.data["abs_time"] = _get_start_time( schedule, timing_constraints[0], operation, time_unit ) schedule = _normalize_absolute_timing(schedule) schedule["duration"] = schedule.get_schedule_duration() if time_unit == "ideal": schedule["depth"] = schedule["duration"] + 1 return schedule
[docs] def _determine_scheduling_strategy(config: CompilationConfig | None = None) -> SchedulingStrategy: if config is not None and config.device_compilation_config is not None: return config.device_compilation_config.scheduling_strategy return SchedulingStrategy.ASAP
[docs] def _validate_schedulable_references( schedule: TimeableSchedule, references_graph: nx.DiGraph ) -> None: """Check the schedulable references for circular references.""" for node in references_graph.nodes: if node not in schedule.schedulables: raise ValueError(f"Node {node} not found in schedulables.") if not nx.is_directed_acyclic_graph(references_graph): raise TypeError( "`schedulable_references` is not a Directed Acyclic Graph. This is most likely " "caused by a circular reference in the Timing Constraints." )
[docs] def _populate_references_graph(schedule: TimeableSchedule) -> nx.DiGraph: """Add nodes and edges to the graph containing schedulable references.""" graph = nx.DiGraph() # Add nodes graph.add_nodes_from(schedule.schedulables.keys()) # Add edges for schedulable_name, schedulable in schedule.schedulables.items(): schedulable_name: str schedulable: Schedulable graph.add_edges_from( (timing_constraint.ref_schedulable, schedulable_name) for timing_constraint in schedulable.data["timing_constraints"] if timing_constraint.ref_schedulable is not None ) return graph
[docs] def _make_timing_constraints_explicit( schedule: TimeableSchedule, strategy: SchedulingStrategy ) -> None: default_schedulable_by_schedulable: list[tuple[str, str | None]] = ( _determine_default_ref_schedulables_by_schedulable(schedule, strategy) ) for ( schedulable_name, default_reference_schedulable_name, ) in default_schedulable_by_schedulable: schedulable_name: str default_reference_schedulable_name: str | None _make_timing_constraints_explicit_for_schedulable( schedule=schedule, schedulable_name=schedulable_name, default_reference_schedulable_name=default_reference_schedulable_name, strategy=strategy, )
[docs] def _make_timing_constraints_explicit_for_schedulable( schedule: TimeableSchedule, schedulable_name: str, default_reference_schedulable_name: str | None, strategy: SchedulingStrategy, ) -> None: schedulable: Schedulable = schedule.schedulables[schedulable_name] given_timing_constraints: list[TimingConstraint] = schedulable.data["timing_constraints"] # Support only one timing constraint for now if strategy == SchedulingStrategy.ALAP and len(given_timing_constraints) != 1: raise NotImplementedError("Only exactly one timing constraint per Schedulable supported.") timing_constraint: TimingConstraint = given_timing_constraints[0] if timing_constraint.ref_schedulable is None: timing_constraint.ref_schedulable = default_reference_schedulable_name if timing_constraint.ref_pt is None: timing_constraint.ref_pt = _determine_default_ref_pt(strategy) if timing_constraint.ref_pt_new is None: timing_constraint.ref_pt_new = _determine_default_ref_pt_new(strategy) if timing_constraint.rel_time is None: timing_constraint.rel_time = 0.0
[docs] def _determine_default_ref_pt(strategy: SchedulingStrategy) -> Literal["start", "end"]: if strategy == SchedulingStrategy.ASAP: return "end" if strategy == SchedulingStrategy.ALAP: return "start" raise ValueError(f"Cannot determine default `ref_pt`. Unknown scheduling strategy: {strategy}")
[docs] def _determine_default_ref_pt_new(strategy: SchedulingStrategy) -> Literal["start", "end"]: if strategy == SchedulingStrategy.ASAP: return "start" if strategy == SchedulingStrategy.ALAP: return "end" raise ValueError( f"Cannot determine default `ref_pt_new`. Unknown scheduling strategy: {strategy}" )
[docs] def _determine_default_ref_schedulables_by_schedulable( schedule: TimeableSchedule, strategy: SchedulingStrategy ) -> list[tuple[str, str | None]]: schedulable_names: list[str] = list(schedule.schedulables) if strategy == SchedulingStrategy.ASAP: default_schedulable_names: list[str | None] = [None] + list(schedule.schedulables)[:-1] elif strategy == SchedulingStrategy.ALAP: default_schedulable_names: list[str | None] = list(schedule.schedulables)[1:] + [None] else: raise ValueError(f"Scheduling strategy {strategy} not one of `ASAP` or `ALAP`.") return [ (schedulable_name, default_schedulable_name) for schedulable_name, default_schedulable_name in zip( schedulable_names, default_schedulable_names, strict=False ) ]
[docs] def _get_start_time( schedule: TimeableSchedule, t_constr: TimingConstraint, curr_op: Operation | TimeableSchedule, time_unit: Literal["physical", "ideal", None], ) -> float: if t_constr.ref_schedulable: ref_schedulable: Schedulable = schedule.schedulables[t_constr.ref_schedulable] ref_op: Operation | TimeableSchedule = schedule.operations[ref_schedulable["operation_id"]] time_ref_op = ref_schedulable["abs_time"] # duration = 1 is useful when e.g., drawing a circuit diagram. if time_unit == "physical": duration_ref_op = ref_op.duration else: duration_ref_op = ( ref_op.body.get("depth", 1) if isinstance(ref_op, ControlFlowOperation) else ref_op.get("depth", 1) ) else: time_ref_op = 0 duration_ref_op = 0 # Type checker does not know that ref_op.duration is not None if time_unit == # "physical" assert duration_ref_op is not None # Nor that rel_time is always float instead of also possibly a string assert isinstance(t_constr["rel_time"], (int, float)) ref_pt = t_constr.ref_pt or "end" if ref_pt == "start": t0 = time_ref_op elif ref_pt == "center": t0 = time_ref_op + duration_ref_op / 2 elif ref_pt == "end": t0 = time_ref_op + duration_ref_op else: raise NotImplementedError(f'Timing "{ref_pt=}" not supported by backend.') if time_unit == "physical": duration_new_op = curr_op.duration else: duration_new_op = ( curr_op.body.get("depth", 1) if isinstance(curr_op, ControlFlowOperation) else curr_op.get("depth", 1) ) assert duration_new_op is not None ref_pt_new = t_constr.ref_pt_new or "start" if ref_pt_new == "start": abs_time = t0 + t_constr.rel_time elif ref_pt_new == "center": abs_time = t0 + t_constr.rel_time - duration_new_op / 2 elif ref_pt_new == "end": abs_time = t0 + t_constr.rel_time - duration_new_op else: raise NotImplementedError(f'Timing "{ref_pt_new=}" not supported by backend.') return abs_time
[docs] def _normalize_absolute_timing( schedule: TimeableSchedule, config: CompilationConfig | None = None, # noqa: ARG001 ) -> TimeableSchedule: # TODO: Support normalization of absolute timing in subschedules # See test_negative_absolute_timing_is_normalized_with_subschedule in test_compilation.py # and https://gitlab.com/quantify-os/quantify-scheduler/-/issues/489 min_time = min(schedulable["abs_time"] for schedulable in schedule.schedulables.values()) if min_time < 0: for schedulable in schedule.schedulables.values(): schedulable["abs_time"] -= min_time return schedule
@overload
[docs] def _unroll_loops( schedule: TimeableSchedule, config: CompilationConfig | None = None, ) -> TimeableSchedule: ...
@overload def _unroll_loops( schedule: Operation, config: CompilationConfig | None = None, ) -> Operation | TimeableSchedule: ... def _unroll_loops( schedule: TimeableSchedule | Operation, config: CompilationConfig | None = None, ): # This is a recursive function, the argument `schedule` is not always a `TimeableSchedule` type, # so we rename it at the beginning to not cause confusion. op = schedule if isinstance(op, TimeableSchedule): for inner_op_key, inner_op in op.operations.items(): op.operations[inner_op_key] = _unroll_loops(schedule=inner_op, config=config) return op elif isinstance(op, ControlFlowOperation): op.body = _unroll_loops(schedule=op.body, config=config) if isinstance(op, LoopOperation) and op.strategy == LoopStrategy.UNROLLED: return _unroll_single_loop(op) else: return op else: return op
[docs] def _unroll_single_loop(op: LoopOperation) -> TimeableSchedule: unrolled_schedule = TimeableSchedule() for rep in range(op.repetitions): variables = {} if op.domain is not None: domains = unpack_complex_variables(op.domain) for var, domain in domains.items(): variables[var] = domain[rep] sub_body = op.body.substitute(variables) if sub_body is op.body: # Actually copy operations when unrolling loops, # so they are logically separate. sub_body = op.body.clone() unrolled_schedule.add(sub_body) return unrolled_schedule
[docs] def validate_config(config: dict, scheme_fn: str) -> bool: """ Validate a configuration using a schema. Parameters ---------- config The configuration to validate scheme_fn The name of a json schema in the qblox_scheduler.schemas folder. Returns ------- : True if valid """ scheme = load_json_schema(__file__, scheme_fn) validate_json(config, scheme) return True
[docs] def plot_schedulable_references_graph(schedule: TimeableSchedule) -> None: """ Show the schedulable reference graph. Can be used as a debugging tool to spot any circular references. """ graph = _populate_references_graph(schedule) nx.draw(graph, with_labels=True)
[docs] def schedule_repetitions_to_loop( schedule: TimeableSchedule, config: CompilationConfig | None = None, # noqa: ARG001 ) -> TimeableSchedule: """ Schedule repetitions are deprecated in favor of the more versatile loops. In implementation, we only process loops to not make the code redundant. We intentionally set the schedule repetitions to `None` to disallow anyone using it. """ repetitions = schedule.repetitions # TODO: schedule.repetitions = None schedule.repetitions = 1 all_clock_freqs = set() _extract_used_clocks(schedule, all_clock_freqs) outer_schedule = TimeableSchedule("schedule") with outer_schedule.loop( arange(0, repetitions, 1, dtype=DType.NUMBER) ) as schedule_repetition_variable: last_reset_clock_phase_schedulable = None for clock in all_clock_freqs: if clock not in (BasebandClockResource.IDENTITY, DigitalClockResource.IDENTITY): last_reset_clock_phase_schedulable = outer_schedule.add( ResetClockPhase(clock), ref_op=last_reset_clock_phase_schedulable, ref_pt="start", ) outer_schedule.add(IdlePulse(duration=4e-9)) _add_schedule_repetition_to_append(schedule, schedule_repetition_variable) outer_schedule.add(schedule) outer_schedule["compiled_instructions"] = {} return outer_schedule
[docs] def _extract_used_clocks( operation: TimeableSchedule | Operation, clocks_used: set[str], ) -> None: if isinstance(operation, TimeableScheduleBase): for inner_op_key in operation.operations: _extract_used_clocks( operation=operation.operations[inner_op_key], clocks_used=clocks_used, ) elif isinstance(operation, (ControlFlowOperation, PulseCompensation)): _extract_used_clocks(operation.body, clocks_used) else: clocks_used.update(extract_clocks_used(operation))
[docs] def _add_schedule_repetition_to_append( operation: Operation | TimeableSchedule, schedule_repetition_variable: Variable ) -> None: if isinstance(operation, TimeableSchedule): for inner_operation in operation.operations.values(): _add_schedule_repetition_to_append(inner_operation, schedule_repetition_variable) if isinstance(operation, ControlFlowOperation): _add_schedule_repetition_to_append(operation.body, schedule_repetition_variable) elif (acquisition_info := operation.data.get("acquisition_info", {})) != {} and ( acquisition_info["bin_mode"] == BinMode.APPEND ): coords = acquisition_info["coords"] or {} repetition_coord = "repetition" if repetition_coord in coords and coords[repetition_coord] != schedule_repetition_variable: raise ValueError( f"Acquisition coords '{repetition_coord}' " f"is not allowed for APPEND mode acquisitions." ) else: coords[repetition_coord] = schedule_repetition_variable acquisition_info["coords"] = coords
[docs] def _variables_in_expression(expr: Expression) -> set[Variable]: """Recursively collect every ``Variable`` referenced within an expression tree.""" if isinstance(expr, BinaryExpression): lhs = _variables_in_expression(expr.lhs) if isinstance(expr.lhs, Expression) else set() rhs = _variables_in_expression(expr.rhs) if isinstance(expr.rhs, Expression) else set() return lhs | rhs elif isinstance(expr, UnaryExpression): return _variables_in_expression(expr.operand) else: assert isinstance(expr, Variable) return {expr}
@overload
[docs] def scale_amplitude_variables( schedule: TimeableSchedule, config: CompilationConfig | None = None, substitutions: dict[Expression, Expression | int | float | complex] | None = None, ) -> TimeableSchedule: ...
@overload def scale_amplitude_variables( schedule: Operation, config: CompilationConfig | None = None, substitutions: dict[Expression, Expression | int | float | complex] | None = None, ) -> Operation: ... def scale_amplitude_variables( schedule: TimeableSchedule | Operation, config: CompilationConfig | None = None, substitutions: dict[Expression, Expression | int | float | complex] | None = None, ): """ Scales each DType.AMPLITUDE and DType.DRAG_BETA domain to be between [-1,+1], and each time the variable is used is scaled back with the same number. This is supposed to be a temporary solution until backend does not allow amplitude variables to be outside of the range [-1,+1]. Currently the backend compiler does not allow variables to go outside of the range [-1,+1] in case the variable is used as an amplitude. But the compiler allows expressions of the form `amplitude=c*var`. So we get around the range problem by scaling the domain ranges and variable usages. """ if substitutions is None: substitutions = {} operation = schedule if isinstance(operation, TimeableSchedule): for inner_op_key, inner_op in operation.operations.items(): operation.operations[inner_op_key] = scale_amplitude_variables( inner_op, config, substitutions ) return operation elif isinstance(operation, LoopOperation): for variable, domain in operation.domain.items(): if domain.dtype in (DType.AMPLITUDE, DType.DRAG_BETA): max_value = max(abs(domain.start), abs(domain.stop)) if max_value > 1 or domain.dtype == DType.DRAG_BETA: domain.start /= max_value domain.stop /= max_value substitutions[variable] = max_value * variable operation.body = scale_amplitude_variables(operation.body, config, substitutions) return operation if isinstance(operation, ControlFlowOperation): operation.body = scale_amplitude_variables(operation.body, config, substitutions) return operation else: return operation.substitute(substitutions) @dataclass
[docs] class _DomainForVariable: """Datastructure to help readability for `scale_phase_domains`."""
[docs] domain: LinearDomain
"""Domain for the variable."""
[docs] domain_dict_to_change: dict[Variable, LinearDomain]
"""When we introduce new domains, we need to do this into this dictionary."""
@overload
[docs] def scale_phase_domains( schedule: TimeableSchedule, config: CompilationConfig | None = None, domain_for_variable: dict[Variable, _DomainForVariable] | None = None, ) -> TimeableSchedule: ...
@overload def scale_phase_domains( schedule: Operation, config: CompilationConfig | None = None, domain_for_variable: dict[Variable, _DomainForVariable] | None = None, ) -> Operation: ... def scale_phase_domains( schedule: TimeableSchedule | Operation, config: CompilationConfig | None = None, domain_for_variable: dict[Variable, _DomainForVariable] | None = None, ): """ Introduces new phase domains for each usage of an expression with a DType.PHASE variable. It's needed, because currently wherever DType.PHASE variable is used, we cannot use a generic expression but only a variable. So we introduce a new compatible domain for every expression is used, and introduce a new variable for that, and use that single variable there instead. This is supposed to be a temporary solution until backend does not allow generic expressions where phase is allowed. This step is only implemented for a few operation types and few arguments of them. Parameters ---------- schedule The schedule. config Compilation config. domain_for_variable This is the domain for a variable we need to modify. We introduce a new variable in place of an expression where a variable is used, and then introduce a new domain in the domain specification. Raises ------ NotImplementedError If a ``phase_shift`` expression still contains a DType.PHASE variable that is not driven by a LoopOperation domain (e.g. a free-standing variable declared via ``TimeableSchedule.declare`` and set via ``Assign``). Such an expression cannot be reduced to the single native ``Variable`` the backend requires. """ if domain_for_variable is None: domain_for_variable = {} operation = schedule if isinstance(operation, TimeableSchedule): for inner_op in operation.operations.values(): scale_phase_domains(inner_op, config, domain_for_variable) elif isinstance(operation, LoopOperation): for variable, domain in operation.domain.items(): if domain.dtype == DType.PHASE: domain_for_variable[variable] = _DomainForVariable(domain, operation.domain) scale_phase_domains(operation.body, config, domain_for_variable) elif isinstance(operation, ControlFlowOperation): scale_phase_domains(operation.body, config, domain_for_variable) elif ( (pulse_info := operation.data.get("pulse_info")) is not None and "phase_shift" in pulse_info and isinstance(pulse_info["phase_shift"], Expression) and not isinstance(pulse_info["phase_shift"], Variable) ): # If the phase_shift is an expression, we potentially need to convert it, # but if it's already a variable, it can be handled natively by the backend compiler, # so we do not need to convert it. for variable, d in domain_for_variable.items(): # We only allow linear domains for now in loops, # and the logic below only works for them. assert isinstance(d.domain, LinearDomain) if variable in pulse_info["phase_shift"] and _variable_is_in_linear_expression( pulse_info["phase_shift"], variable ): new_domain_start = pulse_info["phase_shift"].substitute({variable: d.domain.start}) new_domain_stop = pulse_info["phase_shift"].substitute({variable: d.domain.stop}) if new_domain_start < 0 or new_domain_stop < 0: # Only positive numbers are allowed for phase_shift. shift = 360.0 * math.ceil(abs(min(new_domain_start, new_domain_stop)) / 360) new_domain_start += shift new_domain_stop += shift new_variable = Variable(DType.PHASE) new_domain = LinearDomain( start=new_domain_start, stop=new_domain_stop, num=d.domain.num, dtype=DType.PHASE, ) d.domain_dict_to_change[new_variable] = new_domain pulse_info["phase_shift"] = new_variable if isinstance(pulse_info["phase_shift"], Expression) and not isinstance( pulse_info["phase_shift"], Variable ): # fixme: https://qblox.atlassian.net/browse/QTFY-1340 unknown_variables = _variables_in_expression(pulse_info["phase_shift"]) - set( domain_for_variable.keys() ) if unknown_variables: raise NotImplementedError( f"Operation '{operation.name}' has a phase_shift expression " f"({pulse_info['phase_shift']!r}) that uses a DType.PHASE variable " f"not driven by a LoopOperation domain: {unknown_variables!r}. " "Free-standing phase variables (declared via TimeableSchedule.declare " "and set via Assign) are not yet supported as phase_shift arguments; " "only variables swept via a LoopOperation domain are." ) return operation
[docs] def _variable_is_in_linear_expression( # noqa: PLR0911 expression: Expression | float | int | complex, variable: Variable ) -> bool: match expression: case float() | int(): return True case Variable() as inner_variable: return inner_variable == variable case UnaryExpression(operator="+" | "-", operand=operand): return _variable_is_in_linear_expression(operand, variable) case BinaryExpression(lhs=lhs, operator="+" | "-", rhs=rhs): return _variable_is_in_linear_expression( lhs, variable ) and _variable_is_in_linear_expression(rhs, variable) case BinaryExpression(lhs=lhs, operator="*" | "/", rhs=float() | int()): return _variable_is_in_linear_expression(lhs, variable) case BinaryExpression(lhs=float() | int(), operator="*", rhs=rhs): return _variable_is_in_linear_expression(rhs, variable) case _: return False