Source code for quchip.control.sequence

"""Declarative pulse programming for a :class:`~quchip.chip.chip.Chip`.

:class:`QuantumSequence` is the user-facing scheduling API. It tracks
per-endpoint timing cursors, per-device virtual-Z phase frames, and
cross-device barriers, then materializes the schedule into classical
:class:`~quchip.engine.ir.DriveOp` and field
:class:`~quchip.engine.ir.CoherentOp` records for the engine.

Conventions
-----------
- Times are in ns. Frequencies are in GHz (ordinary, not angular).
- Each carrier pulse follows the virtual-Z frame of one device. By default,
  this is the device that its line drives. Virtual-Z shifts accumulate into
  later carrier pulses in that frame on any line, but not into baseband flux
  pulses. This matches the lab-frame semantics of software-Z (McKay et al.,
  PRA 96, 022330 (2017)).
- All sweep axes stay JAX-traceable. Pulse parameters, delays, and envelope
  fields go into ``SolveProblem`` without Python-side concretization.

Examples
--------
>>> from quchip import (
...     DuffingTransmon, ChargeDrive, Chip, QuantumSequence, Gaussian
... )
>>> q = DuffingTransmon(freq=5.0, anharmonicity=-0.25, levels=3)
>>> drive = ChargeDrive(target=q)
>>> chip = Chip([q])
>>> seq = QuantumSequence(chip)
>>> _ = seq.schedule(q, envelope=Gaussian(duration=20.0, amplitude=0.05))
>>> seq.total_duration
20.0
"""

from __future__ import annotations

import copy
import functools
from dataclasses import dataclass, replace
from collections.abc import Collection, Mapping, Sequence
from types import MappingProxyType
from typing import TYPE_CHECKING, Any

import numpy as np

from quchip.approximations import Approximation, require_approximation
from quchip.chip.chip import Chip
from quchip.chip.coupling_base import BaseCoupling
from quchip.control.batch import (
    BatchAxis,
    DelayHandle,
    PulseHandle,
    ZippedBatchAxis,
    _PULSE_FIELDS,
    _axis_metadata,
    _expand_axis_overrides,
)
from quchip.control.drive import (
    BaseDrive,
    ChargeDrive,
    CouplingDrive,
    FluxDrive,
    PhaseDrive,
)
from quchip.control.envelopes import Envelope
from quchip.control.field import CoherentInput, ControlEndpoint
from quchip.devices.base import BaseDevice
from quchip.engine.ir import StateStorage, CoherentOp, ControlOp, DriveOp, EngineResult
from quchip.engine.assembly import (
    build_engine_result,
    compile_hamiltonian_template,
    instantiate_engine_result,
)
from quchip.engine.frames import resolve_for_operations
from quchip.engine.sampling import AutomaticTimeGrid, interval_bounds, sample_problems
from quchip.engine.problem import (
    build_solve_batch_from_results,
    build_solve_problem,
    prepare_solve_problem_context,
)
from quchip.utils.jax_utils import (
    array_namespace,
    is_jax_array as _is_traced,
    is_jax_namespace,
    maybe_concrete_scalar,
    select_array_module as _select_array_module,
)
from quchip.utils.labeling import resolve_label
from quchip.utils.values import copy_value

if TYPE_CHECKING:
    from quchip.chip.transformations.active_patch import ActivePatchResult
    from quchip.declarative.expr import PhysicsExpr
    from quchip.engine.ir import FrameSpec, SolveBatch, SolveProblem
    from quchip.results.propagator import PropagatorResult
    from quchip.results.results import SimulationBatchResult, SimulationResult


@dataclass(frozen=True)
class _PulseEntry:
    target_label: str
    drive_label: str
    envelope: Envelope
    freq: float | None
    requested_start_time: float | None
    phase: float
    coherent_input: CoherentInput | None = None
    frame: str | None = None
    detuning: Any = None


@dataclass(frozen=True)
class _DelayEntry:
    device_label: str
    duration: Any


@dataclass(frozen=True)
class _BarrierEntry:
    device_labels: tuple[str, ...]


@dataclass(frozen=True)
class _FrameShiftEntry:
    device_label: str
    angle: Any


def _require_positive_duration(duration: Any) -> None:
    """Reject a non-positive *concrete* delay duration; traced values pass through.

    Shared by :meth:`QuantumSequence.delay` (validates the scheduled value)
    and the replay path (validates per-variant batch overrides) — the two
    sites check different values, so both must gate.
    """
    duration_val = maybe_concrete_scalar(duration)
    if duration_val is not None and duration_val <= 0:
        raise ValueError(f"Delay duration must be > 0, got {duration}")


def _cursor_max(values: Collection[Any]) -> Any:
    """Return the maximum of cursor *values*, tracing-safe under ``jax.jit``.

    Python's ``max()`` branches on the result of ``>``/``<``, which raises
    under a JAX trace once any operand is a tracer. Folds with
    ``jnp.maximum`` instead whenever any value is a JAX array; *values*
    must be non-empty.
    """
    xp = _select_array_module(any(_is_traced(v) for v in values))
    return functools.reduce(xp.maximum, values)


[docs] class QuantumSequence: """Declarative pulse sequence builder for a :class:`~quchip.chip.chip.Chip`. Tracks per-``(device, drive)`` timing cursors and per-device virtual-Z phase frames. Append pulses with :meth:`schedule` (or the conveniences :meth:`charge`, :meth:`phase`, :meth:`flux`). Synchronize channels with :meth:`barrier` and :meth:`delay`. :meth:`build_problem` and :meth:`build_batch` materialize the schedule lazily into engine control-operation records. Where a device/drive is expected, you can use the object or its string label. Simulation uses one consistent verb, ``simulate``, across three tiers, from most ergonomic to most explicit: 1. :meth:`simulate` / :meth:`simulate_batch`: schedule *and* solve in one call (the example-facing path). 2. :meth:`~quchip.chip.chip.Chip.solve` / :meth:`~quchip.chip.chip.Chip.solve_many`: solve a :class:`~quchip.engine.ir.SolveProblem` / batch you already hold. 3. The module-level :func:`~quchip.engine.simulate` / :func:`~quchip.engine.solve_problem` / :func:`~quchip.engine.solve_many`: the low-level "I already have ``drive_ops`` / a ``SolveProblem``" tier. Parameters ---------- chip : Chip Chip that this sequence schedules against. It supplies the device and coupling maps used to resolve scheduling targets, the wired :class:`~quchip.control.equipment.ControlEquipment` lines, and the frame/backend settings used by :meth:`build_problem` and :meth:`simulate`. Examples -------- >>> from quchip import ( ... DuffingTransmon, ChargeDrive, Chip, QuantumSequence, Gaussian ... ) >>> q = DuffingTransmon(freq=5.0, anharmonicity=-0.25, levels=3) >>> drive = ChargeDrive(target=q) >>> chip = Chip([q]) >>> chip.wire(drive) >>> seq = QuantumSequence(chip) >>> _ = seq.charge(q, envelope=Gaussian(duration=20.0, amplitude=0.05)) >>> seq.vz(q, angle=0.5) >>> _ = seq.charge(q, envelope=Gaussian(duration=10.0, amplitude=0.03)) """ # Examples should use object references. def __init__(self, chip: Chip) -> None: ambiguous = [ path for path in chip.parameters if len((parts := path.split(".", 2))) == 3 and parts[0] == "pulse" and parts[1].isdigit() ] if ambiguous: raise ValueError( "Component labels matching 'pulse.<integer>' are reserved for " f"scheduled-pulse parameters; conflicting paths: {ambiguous}" ) self._chip = chip # ``_entries`` is the single source of truth for all timing. Cursors, # floors, and virtual-Z phase frames are reconstructed on demand by # ``_replay`` — there is no live cursor state to drift from the replay. self._entries: list[_PulseEntry | _DelayEntry | _BarrierEntry | _FrameShiftEntry] = [] # Control lines and devices of the chip that the entries were written # for, which this chip lacks, e.g. those that an active patch removed. # Replay keeps their cursors and frames, so delays, barriers and # virtual-Z entries on them keep their timing and have no other effect. self._removed_lines: tuple[tuple[str, str], ...] = () self._removed_devices: tuple[str, ...] = () def _device_drives(self, device_label: str) -> list[BaseDrive]: self._chip.device_map[device_label] equipment = self._chip.control_equipment return [] if equipment is None else [ line for line in equipment.lines if not isinstance(line, CouplingDrive) and line.target_label == device_label ] def _find_drive_by_type(self, device_label: str, drive_type: type) -> BaseDrive: matches = [d for d in self._device_drives(device_label) if isinstance(d, drive_type)] if len(matches) == 0: raise ValueError(f"No {drive_type.__name__} on device '{device_label}'.") if len(matches) > 1: raise ValueError( f"Multiple {drive_type.__name__} on '{device_label}': " f"{[d.label for d in matches]}. Use schedule(drive, ...) instead." ) return matches[0] def _find_default_drive(self, device_label: str) -> BaseDrive: drives = self._device_drives(device_label) if not drives: raise ValueError( f"Device '{device_label}' has no connected drives. " "Connect a drive to the device before scheduling." ) return drives[0] def _find_coupling_drive(self, coupling_label: str) -> BaseDrive: """Return the unique control line targeting *coupling_label*.""" equipment = self._chip.control_equipment lines = [] if equipment is None else [ line for line in equipment.lines if isinstance(line, CouplingDrive) and line.target_label == coupling_label ] if len(lines) == 1: return lines[0] if not lines: raise ValueError( f"No coupling drive targets '{coupling_label}'. Wire a CouplingDrive, " "such as ParametricDrive(coupling), before scheduling." ) raise ValueError( f"Multiple coupling drives target '{coupling_label}': {[line.label for line in lines]}. " "Schedule on the drive object or its label instead." ) def _find_drive_line(self, label: str) -> BaseDrive: """Return the control-equipment line named *label* (schedule()'s final string fallback). Resolves a drive's own label directly to that line, independent of its device or coupling target — the only name that survives when a line's original target has been eliminated (a retarget rule preserves the line's label; see :func:`~quchip.chip.transformations.eliminate`). """ equipment = self._chip.control_equipment lines = [] if equipment is None else [line for line in equipment.lines if line.label == label] if lines: return lines[0] line_labels = [] if equipment is None else [line.label for line in equipment.lines] raise ValueError( f"Label '{label}' not found on chip as a device, coupling, or control-line label. " f"Available devices: {list(self._chip.device_map.keys())}. " f"Available couplings: {list(self._chip.coupling_map.keys())}. " f"Available control lines: {line_labels}." ) def _pulse_frame(self, frame: str | BaseDevice | None, freq: Any, default: str | None) -> str | None: """Return the device whose virtual-Z phase a scheduled carrier follows.""" if frame is None: return default if freq is None: raise ValueError("A frame requires a carrier: a pulse without freq has no carrier phase for vz() to shift.") label = resolve_label(frame) if label not in self._chip.device_map: raise ValueError( f"Frame '{label}' is not a device on this chip. " f"Available devices: {list(self._chip.device_map.keys())}" ) return label @staticmethod def _pulse_detuning(detuning: Any, freq: Any) -> Any: """Return a start-referenced carrier offset after checking that the pulse has a carrier.""" if detuning is not None and freq is None: raise ValueError("A detuning requires a carrier: pass freq, the frame frequency, with detuning.") return detuning def _schedule_on_drive( self, drive: BaseDrive, *, envelope: Envelope, freq: float | None, start_time: float | None = None, phase: float = 0.0, frame: str | BaseDevice | None = None, detuning: float | None = None, ) -> PulseHandle: if drive._target is None: raise ValueError( "Cannot schedule an unconnected drive. Connect it to its device " "or coupling first." ) if isinstance(drive, CouplingDrive): target_label = drive.target_label if target_label not in self._chip.coupling_map: raise ValueError( f"Coupling drive '{drive.label}' targets '{target_label}', which is not on this chip. " f"Available couplings: {list(self._chip.coupling_map.keys())}" ) default_frame = None else: target_label = drive.target_label assert target_label is not None if target_label not in self._chip.device_map: raise ValueError( f"Drive is connected to device '{target_label}', which is not on this chip. " f"Available devices: {list(self._chip.device_map.keys())}" ) default_frame = target_label self._entries.append( _PulseEntry( target_label=target_label, drive_label=drive.label, envelope=envelope, freq=freq, requested_start_time=start_time, phase=phase, frame=self._pulse_frame(frame, freq, default_frame), detuning=self._pulse_detuning(detuning, freq), ) ) return PulseHandle(self, len(self._entries) - 1) def _schedule_on_coherent_input( self, coherent_input: CoherentInput, *, envelope: Envelope, freq: float | None, start_time: float | None = None, phase: float = 0.0, frame: str | BaseDevice | None = None, detuning: float | None = None, ) -> PulseHandle: exposures = [channel.key for channel in self._chip.resolve().slh.external_channels] if coherent_input.exposure not in exposures: raise ValueError( f"Unknown coherent-input exposure {coherent_input.exposure!r}. " f"Available exposures: {exposures}." ) self._entries.append( _PulseEntry( target_label=coherent_input.exposure, drive_label=coherent_input.label, envelope=envelope, freq=freq, requested_start_time=start_time, phase=phase, coherent_input=coherent_input, frame=self._pulse_frame(frame, freq, None), detuning=self._pulse_detuning(detuning, freq), ) ) return PulseHandle(self, len(self._entries) - 1)
[docs] def schedule( self, target: str | ControlEndpoint | BaseDevice | BaseCoupling, *, envelope: Envelope, freq: float | None = None, start_time: float | None = None, phase: float = 0.0, frame: str | BaseDevice | None = None, detuning: float | None = None, ) -> PulseHandle: """Schedule a pulse on *target*. Parameters ---------- target : str | ControlEndpoint | BaseDevice | BaseCoupling Accepted forms, resolved in this order: * ``network.expose(...).input``: scheduled at that external reference plane without entering ``ControlEquipment``. * :class:`BaseDrive`: scheduled directly on that drive. * :class:`BaseDevice`: uses the device's first connected drive. If a device has multiple drives, pass the drive object explicitly. * :class:`~quchip.chip.coupling_base.BaseCoupling` — uses the unique :class:`~quchip.control.drive.CouplingDrive` that targets that coupling. If a coupling has multiple control lines, pass the drive object explicitly. * ``str``: a label, resolved in this order. First, a device label. Then, only when the label is absent from the device map, a coupling label (the two label spaces are disjoint). Then, only when the label is absent from both, a control-equipment line label, scheduled directly on that line. This third fallback lets a caller schedule by a drive's label after its device or coupling target is eliminated. See the retarget registry of :func:`~quchip.chip.transformations.eliminate`, which keeps a converted line's label. A control-line label that collides with a device/coupling label is shadowed by the device/coupling resolution. envelope : Envelope Pulse envelope. freq : float, optional Optional carrier frequency in GHz. Omitting it leaves the signal carrier-free. Drive-specific conveniences can supply their own explicit default, such as :meth:`charge` using ``chip.freq(device)``. start_time : float, optional Pulse start time in ns. Defaults to the current cursor; earlier times are rejected. phase : float Per-pulse phase offset, composed with any accumulated virtual-Z. frame : str or BaseDevice, optional Device whose virtual-Z frame the carrier follows. :meth:`vz` on that device shifts this pulse's phase. Defaults to the device that the drive line targets. Coupling-drive and coherent-input pulses have no default frame. A cross-resonance tone on the control's line names the target device, because its phase sets the axis of the target's conditional rotation. A frame requires ``freq``. detuning : float, optional Carrier offset ``δ`` in GHz, referenced to the pulse start ``t0``. The pulse delivers ``E(t - t0) exp(i phase) exp(-2πi freq t) exp(-2πi δ (t - t0))``. ``freq`` stays the frame frequency, so the pulse is the same in that frame at every ``t0``. A carrier at ``freq = f + δ`` without ``detuning`` is a fixed oscillator instead, with phase ``phase - 2π δ t0`` relative to the frame at ``f``. ``None`` adds no offset term. ``pulse.<index>.detuning`` rebinds, sweeps, and differentiates like ``freq``. A detuning requires ``freq``. """ if isinstance(target, CoherentInput): return self._schedule_on_coherent_input( target, envelope=envelope, freq=freq, start_time=start_time, phase=phase, frame=frame, detuning=detuning, ) if isinstance(target, BaseDrive): drive = target else: label = resolve_label(target) if label in self._chip.device_map: drive = self._find_default_drive(label) elif label in self._chip.coupling_map: drive = self._find_coupling_drive(label) else: drive = self._find_drive_line(label) return self._schedule_on_drive( drive, envelope=envelope, freq=freq, start_time=start_time, phase=phase, frame=frame, detuning=detuning, )
[docs] def charge( self, target: str | BaseDevice, *, envelope: Envelope, freq: float | None = None, phase: float = 0.0, frame: str | BaseDevice | None = None, detuning: float | None = None, ) -> PulseHandle: """Schedule a charge-drive pulse. Parameters ---------- target : str or BaseDevice Driven device. envelope : Envelope Pulse envelope; time parameters are in ns and amplitudes in GHz. freq : float or None, default=None Carrier frequency in GHz; ``None`` uses ``chip.freq(target)``. phase : float, default=0.0 Carrier phase in radians. frame : str or BaseDevice or None, default=None Device whose virtual-Z frame the carrier follows. ``None`` uses *target*. See :meth:`schedule`. detuning : float or None, default=None Carrier offset in GHz, referenced to the pulse start. See :meth:`schedule`. """ label = resolve_label(target) self._validate_target(label) drive = self._find_drive_by_type(label, ChargeDrive) if freq is None: freq = self._chip.freq(label) return self._schedule_on_drive( drive, envelope=envelope, freq=freq, phase=phase, frame=frame, detuning=detuning, )
[docs] def phase( self, target: str | BaseDevice, *, envelope: Envelope, freq: float, phase: float = 0.0, frame: str | BaseDevice | None = None, detuning: float | None = None, ) -> PulseHandle: """Schedule a phase-drive pulse. Parameters ---------- target : str or BaseDevice Driven device. envelope : Envelope Pulse envelope; time parameters are in ns and amplitudes in GHz. freq : float Carrier frequency in GHz. phase : float, default=0.0 Carrier phase in radians. frame : str or BaseDevice or None, default=None Device whose virtual-Z frame the carrier follows. ``None`` uses *target*. See :meth:`schedule`. detuning : float or None, default=None Carrier offset in GHz, referenced to the pulse start. See :meth:`schedule`. """ label = resolve_label(target) self._validate_target(label) drive = self._find_drive_by_type(label, PhaseDrive) return self._schedule_on_drive( drive, envelope=envelope, freq=freq, phase=phase, frame=frame, detuning=detuning, )
[docs] def flux( self, target: str | BaseDevice, *, envelope: Envelope, ) -> PulseHandle: """Schedule a baseband flux-drive pulse. Parameters ---------- target : str or BaseDevice Driven flux-tunable device. envelope : Envelope Baseband pulse envelope in GHz versus time in ns. """ label = resolve_label(target) self._validate_target(label) drive = self._find_drive_by_type(label, FluxDrive) return self._schedule_on_drive(drive, envelope=envelope, freq=None)
[docs] def pump( self, coupling: str | BaseCoupling, *, envelope: Envelope, freq: float | None = None, start_time: float | None = None, phase: float = 0.0, ) -> PulseHandle: """Schedule an edge pump. Parameters ---------- coupling : str or BaseCoupling Modulable coupling to pump. envelope : Envelope Pump-amplitude envelope in GHz versus time in ns. freq : float or None, default=None Carrier frequency in GHz; ``None`` applies the envelope at baseband. start_time : float or None, default=None Absolute start time in ns; ``None`` uses the channel cursor. phase : float, default=0.0 Carrier phase in radians. """ label = resolve_label(coupling) drive = self._find_coupling_drive(label) return self._schedule_on_drive(drive, envelope=envelope, freq=freq, start_time=start_time, phase=phase)
[docs] def flux_to( self, target: str | BaseDevice, *, target_freq: Any, envelope: Envelope, ) -> PulseHandle: """Schedule a flux-drive frequency-shift pulse to ``target_freq``. This is not an inverse-SQUID flux calibration: it calculates ``δω = target_freq − chip.freq(device)`` and schedules a :class:`FluxDrive` pulse whose envelope amplitude is that frequency shift in GHz. The resulting Hamiltonian contribution is the linear detuning term ``δω(t) n̂``. Pass ``envelope`` with ``amplitude=None`` (or any placeholder). This method replaces the amplitude with the calculated δω. ``target_freq`` can be a JAX tracer (for example ``chip.freq(other_device)``). Parameters ---------- target : str | BaseDevice Device to flux-pulse. target_freq : float Target ``0 → 1`` frequency in GHz. envelope : Envelope Envelope template with all timing parameters set. Its ``amplitude`` is replaced by the calculated δω, so any passed value is ignored. Returns ------- PulseHandle Handle to the scheduled entry, usable with ``.vary()``. """ label = resolve_label(target) self._validate_target(label) device = self._chip.device_map[label] current_freq = self._chip.freq(device) delta_omega = target_freq - current_freq pulse = envelope.with_params({"amplitude": delta_omega}) drive = self._find_drive_by_type(label, FluxDrive) return self._schedule_on_drive(drive, envelope=pulse, freq=None)
[docs] def vz(self, target: str | BaseDevice, angle: float) -> None: """Apply a virtual-Z frame shift of *angle* rad on *target*. The shift is free (no pulse is emitted). It accumulates into the ``phase_offset`` of every later carrier pulse whose frame is *target*, on any line. A pulse's frame defaults to the device that its line drives, and ``schedule(..., frame=...)`` names another device. This matches the standard software-Z trick for transmons (McKay et al., PRA 96, 022330 (2017)). Baseband flux pulses are not affected. Parameters ---------- target : str or BaseDevice Device receiving the virtual frame shift. angle : float Z-frame shift in radians. """ label = resolve_label(target) self._validate_target(label) self._entries.append(_FrameShiftEntry(device_label=label, angle=angle))
[docs] def delay(self, scope: str | BaseDevice, duration: float) -> DelayHandle: """Insert an idle delay on all drive channels of a target. Parameters ---------- scope : str or BaseDevice Device label or object whose channels advance. duration : float Delay in ns. """ target = resolve_label(scope) self._validate_target(target) _require_positive_duration(duration) self._entries.append(_DelayEntry(device_label=target, duration=duration)) return DelayHandle(self, len(self._entries) - 1)
[docs] def barrier(self, *labels: str | BaseDevice) -> None: """Synchronize channel cursors. With no arguments, every ``(device, drive)`` cursor on the chip is advanced to the current maximum. With explicit device targets, only those devices' cursors are aligned. This guarantees that pulses scheduled after the barrier start no earlier than any pulse scheduled before it. Parameters ---------- *labels : str or BaseDevice Devices to synchronize. If omitted, all device-drive cursors are synchronized. """ if not labels: self._entries.append(_BarrierEntry(device_labels=())) return resolved = tuple(resolve_label(lbl) for lbl in labels) for lbl in resolved: self._validate_target(lbl) self._entries.append(_BarrierEntry(device_labels=resolved))
@property def total_duration(self) -> Any: """Total sequence duration in ns (maximum across all cursors).""" cursors, _ = self._replay_cursors() return _cursor_max(cursors.values()) if cursors else 0.0 @property def scheduled_ops(self) -> tuple[ControlOp, ...]: """Materialize scheduled classical-drive and coherent-field operations.""" return tuple(self._materialize_drive_ops()) @property def channel_cursors(self) -> dict[tuple[str, str], Any]: """Current time cursors keyed by ``(target_label, drive_label)``.""" return self._replay_cursors()[0] def _resolve_tlist( self, tlist: Any | None, *, duration: Any | None = None, overrides: Mapping[tuple[int, str], Any] | None = None, ) -> Any: """Preserve an explicit grid, or sample an interval starting at zero. A duration may extend the schedule. Explicit grids may select any partial interval; their first time is the initial-state time. """ if tlist is not None: if duration is not None: raise ValueError("duration and tlist are mutually exclusive.") return tlist if isinstance(tlist, tuple) or is_jax_namespace(array_namespace(tlist)) else np.asarray(tlist) cursors, _ = self._replay_cursors(overrides) end = maybe_concrete_scalar(_cursor_max(cursors.values()) if cursors else 0.0) if end is None: raise ValueError( "Automatic sampling requires concrete sequence timing. " "Pass an explicit tlist when timing depends on traced parameters." ) value = end if duration is None else maybe_concrete_scalar(duration) if value is None: raise ValueError("Automatic sampling requires a concrete duration; pass an explicit tlist.") if not np.isfinite(value) or value <= 0: raise ValueError("Provide a finite, positive duration or an explicit tlist.") if not np.isfinite(end) or value < end: raise ValueError(f"duration must reach the schedule end at {end:g} ns.") return AutomaticTimeGrid(value) def _replay( self, overrides: Mapping[tuple[int, str], Any] | None = None, *, collect_ops: bool, ) -> tuple[dict[tuple[str, str], Any], dict[str, Any], list[ControlOp]]: """Replay ``_entries`` into final cursors, floors, and optional control ops. This is the single implementation of the delay-shift, barrier-alignment, floor, default-start, and virtual-Z phase semantics. Both the cursor accessors (:meth:`_replay_cursors`) and materialization (:meth:`_materialize_drive_ops`) run through it, so there is no way for a cursor read and a materialized schedule to disagree. Replaying the whole entry list on each call is O(n) per invocation (O(n^2) across a full build); this is an intentional trade for a single source of truth, and sequences carry only modest pulse counts. ``overrides`` maps ``(entry_index, field) -> value``; only ``duration`` (on a delay entry or pulse envelope) and the pulse fields ``start_time``/``freq``/``phase``/``detuning`` affect the replay. ``collect_ops`` gates operation building and the virtual-Z phase of each pulse's frame. """ overrides = {} if overrides is None else dict(overrides) cursors: dict[tuple[str, str], Any] = dict.fromkeys(self._line_keys(), 0.0) for entry in self._entries: if isinstance(entry, _PulseEntry) and entry.coherent_input is not None: cursors[(entry.target_label, entry.drive_label)] = 0.0 floors: dict[str, Any] = {} phases: dict[str, Any] = dict.fromkeys(self._device_labels(), 0.0) drive_ops: list[ControlOp] = [] for entry_index, entry in enumerate(self._entries): if isinstance(entry, _FrameShiftEntry): phases[entry.device_label] = phases[entry.device_label] + entry.angle continue if isinstance(entry, _DelayEntry): duration = overrides.get((entry_index, "duration"), entry.duration) _require_positive_duration(duration) for key in list(cursors.keys()): if key[0] == entry.device_label: cursors[key] += duration floors[entry.device_label] = floors.get(entry.device_label, 0.0) + duration continue if isinstance(entry, _BarrierEntry): if not entry.device_labels: max_time = _cursor_max(cursors.values()) if cursors else 0.0 for key in cursors: cursors[key] = max_time for dev in self._chip.devices: floors[dev.label] = max_time continue matching_keys = [key for key in cursors if key[0] in entry.device_labels] if matching_keys: max_time = _cursor_max([cursors[key] for key in matching_keys]) for key in matching_keys: cursors[key] = max_time for label in entry.device_labels: floors[label] = max_time continue if not isinstance(entry, _PulseEntry): continue envelope_updates = { field.removeprefix("envelope."): value for (idx, field), value in overrides.items() if idx == entry_index and (field.startswith("envelope.") or field not in _PULSE_FIELDS) } envelope = ( entry.envelope.with_params(envelope_updates) if envelope_updates else entry.envelope ) requested_start_time = overrides.get((entry_index, "start_time"), entry.requested_start_time) key = (entry.target_label, entry.drive_label) if key not in cursors: cursors[key] = floors.get(entry.target_label, 0.0) current_cursor = cursors[key] start_val = maybe_concrete_scalar(requested_start_time) cursor_val = maybe_concrete_scalar(current_cursor) if ( requested_start_time is not None and start_val is not None and cursor_val is not None and start_val < cursor_val ): raise ValueError( "Explicit start_time cannot be earlier than current cursor " f"for '{entry.target_label}:{entry.drive_label}'. start_time={requested_start_time}, " f"current_cursor={current_cursor}." ) start_time = current_cursor if requested_start_time is None else requested_start_time if collect_ops: freq = overrides.get((entry_index, "freq"), entry.freq) phase = overrides.get((entry_index, "phase"), entry.phase) detuning = self._pulse_detuning(overrides.get((entry_index, "detuning"), entry.detuning), freq) # A carrier follows its frame's virtual-Z phase. A baseband pulse has no carrier phase. frame = None if freq is None else entry.frame phase_offset = phase if frame is None else phase + phases[frame] if entry.coherent_input is None: drive_ops.append( DriveOp( target_label=entry.target_label, envelope=envelope, freq=freq, start_time=start_time, phase_offset=phase_offset, drive_label=entry.drive_label, frame=frame, detuning=detuning, ) ) else: drive_ops.append( CoherentOp( coherent_input=entry.coherent_input, envelope=envelope, freq=freq, start_time=start_time, phase_offset=phase_offset, frame=frame, detuning=detuning, ) ) cursors[key] = start_time + envelope.duration return cursors, floors, drive_ops def _replay_cursors( self, overrides: Mapping[tuple[int, str], Any] | None = None ) -> tuple[dict[tuple[str, str], Any], dict[str, Any]]: """Replay ``_entries`` to final ``(cursors, floors)`` without building operations.""" cursors, floors, _ = self._replay(overrides, collect_ops=False) return cursors, floors def _materialize_drive_ops(self, overrides: Mapping[tuple[int, str], Any] | None = None) -> list[ControlOp]: """Replay ``_entries`` into the scheduled control-operation list.""" return self._replay(overrides, collect_ops=True)[2]
[docs] def build_problem( self, tlist: Any | None = None, solver: str | None = None, options: dict | None = None, e_ops: dict | None = None, initial_state: Any | None = None, approximation: Approximation | None = None, frame: FrameSpec | None = None, states: StateStorage | None = None, dissipation: bool = True, run_args: dict | None = None, *, duration: Any | None = None, ) -> "SolveProblem": """Build a single :class:`~quchip.engine.ir.SolveProblem` from this sequence. Parameters ---------- tlist : array-like, optional Grid passed to the numerical solver, in ns. Its first time is the initial-state time and its last time ends the calculation. Scheduled signals keep their absolute times in a partial interval. duration : float, optional Interval from zero in ns, with automatic sampling. It can extend the schedule but cannot cut it short, and cannot be combined with ``tlist``. Without either argument, the scheduled duration is used. solver : str, optional Backend solver name; defaults to the backend's own default solver. options : dict, optional Backend numerical options. Use ``states`` to select state retention. states : {"all", "final", "none"} or None, default None None saves all deterministic states and preserves native stochastic defaults. Explicit values keep the full state history, only the final state, or neither. Requested observable traces are kept independently. run_args : dict or None, default None Native trajectory call keywords: QuTiP seeds, ntraj, heterodyne, target_tol, timeout; Dynamiqs keys, method, gradient, etas. Assembled physics and native options cannot be overridden here. e_ops : dict, optional Expectation operators keyed by device label (or a 2-tuple of device labels for a two-body operator), mapped to a local operator (or a pair of local operators). They are decomposed into per-band terms before reaching the solver. initial_state : Any, optional Initial state; see :meth:`simulate` for accepted forms, coordinate conventions, and the meaning of ``None``. approximation : Approximation, optional Engine approximation override. ``None`` uses the chip default. frame : FrameSpec, optional Integration-frame override; defaults to the chip's declared frame. ``"auto"`` derives constraints from delivered scheduled signals and weights them over ``tlist[-1] - tlist[0]``. dissipation : bool, default=True Include authored collapse channels when true. Returns ------- SolveProblem Frozen problem (chip, compiled engine result, initial state, ``tlist``, collapse operators, and expectation operators), ready for :meth:`~quchip.chip.chip.Chip.solve` or :func:`~quchip.engine.solve_problem`. """ actual_tlist = self._resolve_tlist(tlist, duration=duration) drive_ops = self._materialize_drive_ops() return build_solve_problem( self._chip, drive_ops, actual_tlist, solver=solver, options=options, run_args=run_args, e_ops=e_ops, initial_state=initial_state, approximation=approximation, states=states, dissipation=dissipation, frame=frame, )
[docs] def resolve( self, *, frame: FrameSpec | None = None, approximation: Approximation | None = None, ) -> EngineResult: """Resolve the backend-neutral Hamiltonian and noise description. Parameters ---------- frame : FrameSpec or None, default=None Resolution frame, or ``None`` to use the chip default. approximation : Approximation or None, default=None Engine approximation, or ``None`` to use the chip default. """ drive_ops = self._materialize_drive_ops() base_result = resolve_for_operations(self._chip, drive_ops, frame=frame, approximation=approximation) return build_engine_result( self._chip, drive_ops, resolved_frame=base_result.resolved_frame, approximation=base_result.approximation, _base_result=base_result, )
[docs] def hamiltonian(self) -> PhysicsExpr: """Return this sequence's canonical time-dependent Hamiltonian.""" return self.resolve().hamiltonian()
[docs] def vary(self, field: str, values: Any, *, name: str | None = None) -> BatchAxis: """Create a :class:`BatchAxis` over a public parameter path or state. Parameters ---------- field : str ``"initial_state"`` or any dotted path exposed by :attr:`parameters`. Pulse handles remain the concise way to vary a pulse field immediately after scheduling it. values : array-like Sequence of values for ``field``, one per batch point. name : str, optional Axis name recorded in :attr:`SolveBatch.axes` and :meth:`SolveBatch.params_at`. Defaults to *field*. Returns ------- BatchAxis Sequence-level axis, consumable by :meth:`build_batch` or :meth:`zip`. """ if field == "initial_state": return BatchAxis(owner=self, target_kind="sequence", field=field, values=values, name=name or field) if field not in self.parameters: raise ValueError( f"Parameter path {field!r} is not batchable. Available: {list(self.parameters)}" ) parts = field.split(".", 2) if len(parts) == 3 and parts[0] == "pulse" and parts[1].isdigit(): index = int(parts[1]) entry = self._entries[index] entry_field = parts[2] return BatchAxis( owner=self, target_kind="entry", field=field, values=values, name=name or field, entry_index=index, entry_field=entry_field, entry=entry, ) return BatchAxis(owner=self, target_kind="parameter", field=field, values=values, name=name or field)
[docs] def zip(self, *axes: BatchAxis) -> ZippedBatchAxis: """Zip axes into one pairwise dimension. Parameters ---------- *axes : BatchAxis Two or more axes to zip, each created by this sequence (:meth:`vary`, :meth:`PulseHandle.vary`, or :meth:`DelayHandle.vary`). All axes need the same length. Returns ------- ZippedBatchAxis Combined axis that :meth:`build_batch` treats as a single dimension: point ``i`` supplies point ``i`` from every zipped axis together, not the outer product. """ if not axes: raise ValueError("zip() requires at least one axis") for axis in axes: if axis.owner is not self: raise ValueError("All axes passed to QuantumSequence.zip() must belong to this sequence") sizes = {axis.size for axis in axes} if len(sizes) != 1: raise ValueError(f"Zipped axes must have equal lengths, got {sorted(sizes)}") return ZippedBatchAxis(axes=tuple(axes))
def _validate_axes(self, axes: Sequence[BatchAxis | ZippedBatchAxis]) -> None: seen_names: set[str] = set() seen_targets: set[tuple[int | None, str]] = set() for axis in axes: members = axis.axes if isinstance(axis, ZippedBatchAxis) else (axis,) for member in members: if member.owner is not self: raise ValueError("All batch axes must belong to this QuantumSequence") if member.name in seen_names: raise ValueError( f"Duplicate batch axis name {member.name!r}; give each axis a " "unique name via vary(..., name=...)." ) seen_names.add(member.name) if member.override_key in seen_targets: raise ValueError( f"Batch axis {member.name!r} binds the same parameter as another axis; " "each parameter can be varied only once." ) seen_targets.add(member.override_key) if member.entry_index is not None and ( member.entry_index >= len(self._entries) or self._entries[member.entry_index] is not member.entry ): raise ValueError( f"Batch axis {member.name!r} no longer matches its scheduled entry — " "the sequence was modified after vary() was called. Re-schedule and " "create the axis from a fresh handle." )
[docs] def build_batch( self, *axes: BatchAxis | ZippedBatchAxis, tlist: Any | None = None, solver: str | None = None, options: dict | None = None, e_ops: dict | None = None, initial_state: Any | None = None, approximation: Approximation | None = None, states: StateStorage | None = None, dissipation: bool = True, run_args: dict | None = None, duration: Any | None = None, ) -> "SolveBatch": """Build a batched solve request from explicit sweep axes. Parameters ---------- *axes : BatchAxis or ZippedBatchAxis Independent or pairwise-zipped sweep axes. tlist : array-like or None, default=None Solver time grid in ns. solver : str or None, default=None Backend solver name; ``None`` uses its default. options : dict or None, default=None Backend numerical options. run_args : dict or None, default None Native trajectory call keywords: QuTiP seeds, ntraj, heterodyne, target_tol, timeout; Dynamiqs keys, method, gradient, etas. Assembled physics and native options cannot be overridden here. e_ops : dict or None, default=None Local expectation-operator specifications. initial_state : Any or None, default=None State reused at every batch point; see :meth:`simulate` for accepted forms and coordinate conventions. An ``initial_state`` axis supplies one state per point. approximation : Approximation or None, default=None Engine approximation override. states : {"all", "final", "none"} or None, default=None None saves all deterministic states and preserves native stochastic defaults. Explicit values select state retention; native storage conflicts raise. dissipation : bool, default=True Include authored collapse channels when true. duration : float or None, default=None Automatically sampled interval from zero, in ns. """ self._validate_axes(axes) shape, expanded = _expand_axis_overrides(axes) params_store = np.empty(shape if shape else (), dtype=object) parameter_axes = any( member.target_kind == "parameter" for axis in axes for member in (axis.axes if isinstance(axis, ZippedBatchAxis) else (axis,)) ) point_tlists = [ self._resolve_tlist(tlist, duration=duration, overrides=self._entry_overrides(overrides)) for _, overrides in expanded ] actual_tlist = point_tlists[0] if point_tlists else self._resolve_tlist(tlist, duration=duration) different_intervals = tlist is None and any( grid != actual_tlist for grid in point_tlists ) if parameter_axes or different_intervals or self._auto_frames_differ(expanded, actual_tlist, approximation): # Every point proves its exact space from the union of all initial states. all_states = [overrides.get((None, "initial_state"), initial_state) for _, overrides in expanded] builders: list[Any] = [] for (coord, overrides), point_tlist in zip(expanded, point_tlists): parameter_bindings = { field: value for (index, field), value in overrides.items() if index is None and field != "initial_state" } entry_overrides = self._entry_overrides(overrides) axis_initial_state = overrides.get((None, "initial_state")) if axis_initial_state is not None and initial_state is not None: raise ValueError( "initial_state may be provided either as a shared scalar or as a batch axis, not both" ) variant = self.with_params(parameter_bindings) drive_ops = variant._materialize_drive_ops(entry_overrides) builders.append( functools.partial( build_solve_problem, variant._chip, drive_ops, point_tlist.bounds if isinstance(point_tlist, AutomaticTimeGrid) else point_tlist, solver=solver, options=options, run_args=run_args, e_ops=e_ops, initial_state=(axis_initial_state if axis_initial_state is not None else initial_state), approximation=approximation, states=states, dissipation=dissipation, _initial_states=all_states, ) ) params_store[coord] = self._point_params(axes, coord) problems = [build() for build in builders] if len({tuple(problem.engine_result.dims) for problem in problems}) > 1: # The points proved different exact spaces. One product space keeps the batch homogeneous. problems = [build(_product_space=True) for build in builders] from quchip.engine.ir import SolveBatch if tlist is None: problems = sample_problems(problems) from quchip.engine.problem import assign_point_noise return SolveBatch( chip=self._chip, problems=tuple(assign_point_noise(problems, split_keys=True)), params=params_store, shape=shape, axes=tuple(_axis_metadata(axis) for axis in axes), ) reference_drive_ops = self._materialize_drive_ops() context = prepare_solve_problem_context( self._chip, actual_tlist, solver=solver, options=options, run_args=run_args, e_ops=e_ops, drive_ops=reference_drive_ops, approximation=approximation, states=states, dissipation=dissipation, initial_states=[overrides.get((None, "initial_state"), initial_state) for _, overrides in expanded], ) template = compile_hamiltonian_template( self._chip, reference_drive_ops, resolved_frame=context.resolved_frame, approximation=context.approximation, _base_result=context._base_result, ) reference_result = instantiate_engine_result(template, reference_drive_ops, self._chip) engine_results: list[Any] = [] initial_states: list[Any] = [] for coord, overrides in expanded: axis_initial_state = overrides.get((None, "initial_state")) entry_overrides = self._entry_overrides(overrides) if axis_initial_state is not None and initial_state is not None: raise ValueError("initial_state may be provided either as a shared scalar or as a batch axis, not both") engine_result = reference_result if entry_overrides: # Scalar-only entry overrides keep the skeleton; engine errors must propagate. drive_ops = self._materialize_drive_ops(entry_overrides) engine_result = instantiate_engine_result(template, drive_ops, self._chip) engine_results.append(engine_result) initial_states.append(axis_initial_state if axis_initial_state is not None else initial_state) params_store[coord] = self._point_params(axes, coord) batch = build_solve_batch_from_results( context, engine_results, initial_states=initial_states ) from quchip.engine.problem import assign_point_noise return replace( batch, problems=tuple(assign_point_noise(list(batch.problems), split_keys=True)), params=params_store, shape=shape, axes=tuple(_axis_metadata(axis) for axis in axes), )
def _auto_frames_differ(self, expanded: Any, tlist: Any, approximation: Approximation | None) -> bool: """Return whether entry-axis values produce distinct or traced ``"auto"`` frames. A true result sends the batch through per-point problem construction. """ from quchip.engine.frames import plan_for_operations if not (isinstance(self._chip.frame, str) and self._chip.frame == "auto"): return False strategy = self._chip.approximation if approximation is None else require_approximation(approximation) window = interval_bounds(tlist) reference = plan_for_operations(self._chip, "auto", self._materialize_drive_ops(), approximation=strategy, solve_window=window) try: keys: set[Any] = {reference.concrete_key()} except ValueError: return True for _, overrides in expanded: drive_ops = self._materialize_drive_ops(self._entry_overrides(overrides)) plan = plan_for_operations(self._chip, "auto", drive_ops, approximation=strategy, solve_window=window) try: keys.add(plan.concrete_key()) except ValueError: return True if len(keys) > 1: return True return False @staticmethod def _entry_overrides(overrides: Mapping[tuple[int | None, str], Any]) -> dict[tuple[int, str], Any]: """Return only the per-entry (non-shared) overrides of one batch point.""" return {(index, field): value for (index, field), value in overrides.items() if index is not None} @staticmethod def _point_params( axes: Sequence[BatchAxis | ZippedBatchAxis], coord: tuple[int, ...] ) -> dict[str, Any]: """Return public axis values at one Cartesian coordinate.""" point: dict[str, Any] = {} for dim, axis in enumerate(axes): members = axis.axes if isinstance(axis, ZippedBatchAxis) else (axis,) for member in members: point[member.name] = member.values[coord[dim]] return point
[docs] def simulate( self, tlist: Any | None = None, solver: str | None = None, options: dict | None = None, e_ops: dict | None = None, initial_state: Any | None = None, *, backend: Any | None = None, partition: bool = True, approximation: Approximation | None = None, states: StateStorage | None = None, dissipation: bool = True, run_args: dict | None = None, duration: Any | None = None, ) -> "SimulationResult": """Build and solve one scheduled simulation. Parameters ---------- tlist : array-like, optional Sample times in ns. Do not use with ``duration``. If omitted, quchip builds an automatic grid through the schedule end. solver : str, optional Backend solver name, such as ``"mesolve"`` or ``"sesolve"``. options : dict, optional Backend-specific solver options. run_args : dict or None, default None Native trajectory call keywords: QuTiP seeds, ntraj, heterodyne, target_tol, timeout; Dynamiqs keys, method, gradient, etas. Assembled physics and native options cannot be overridden here. e_ops : dict, optional Named observables to evaluate. initial_state : object or mapping, optional A label of local energy levels, such as ``{"q0": 1}``, or configured string shorthand names its dressed state: the eigenstate of the solve's undriven static lab-frame Hamiltonian, as kept by its approximation and excitation cutoff, that is assigned to the label. Its overlap with the bare product is made real and nonnegative, and it is placed in the solve frame at ``tlist[0]`` as the label target of ``overlap()``. ``None`` and ``{}`` give the all-ground label. With ordinary couplings under the default RWA, that state is the bare vacuum. When the solve uses the chip's approximation, a label start is the same physical state as ``chip.state()`` before the frame transform. At a nonzero rotating-frame start time, the frame transform is applied at ``tlist[0]``, so the state need not match the lab-frame vector returned by ``chip.state()``. ``chip.bare_state(...)`` gives a bare product, and a mapping with a local ket raises. QuTiP ``Qobj`` and dynamiqs ``QArray`` kets or density matrices use resolved solver coordinates. Arrays, symbolic expressions, and callables are authored-space kets projected onto kept levels. A ``CompactState`` reuses captured excitation coordinates and requires the same local energy bases. backend : object or {"qutip", "dynamiqs"}, optional Per-call backend override. partition : bool, default=True Solve independent chip components separately when the initial state permits it. approximation : Approximation, optional Override the chip's approximation for this solve. states : {"all", "final", "none"} or None, default=None None saves all deterministic states and preserves native stochastic defaults. State storage policy for the returned result. dissipation : bool, default=True Include declared collapse channels when ``True``. duration : float, optional Positive simulation end time in ns when ``tlist`` is omitted. Returns ------- SimulationResult Time samples, requested observables, and states according to ``states``. Raises ------ ValueError If ``tlist`` and ``duration`` are both supplied, timing is invalid, or automatic sampling lacks the concrete timing it requires. Notes ----- A per-call ``backend`` outranks the chip and process defaults, and foreign-native initial states are coerced at the solve boundary. Python evaluates an inline ``initial_state=chip.state(...)`` before this scope opens, so a traced state still requires a JAX-capable surrounding backend. Partitioning returns a :class:`~quchip.results.partitioned.PartitionedSimulationResult` and requires ``initial_state`` to be ``None`` or a mapping; string shorthand and concrete states take the joint path. """ from quchip.engine import simulate as _engine_simulate with self._scoped_backend(backend): actual_tlist = self._resolve_tlist(tlist, duration=duration) drive_ops = self._materialize_drive_ops() return _engine_simulate( self._chip, drive_ops, actual_tlist, solver=solver, options=options, run_args=run_args, e_ops=e_ops, initial_state=initial_state, partition=partition, approximation=approximation, states=states, dissipation=dissipation, )
[docs] def simulate_batch( self, *axes: BatchAxis | ZippedBatchAxis, tlist: Any | None = None, solver: str | None = None, options: dict | None = None, e_ops: dict | None = None, initial_state: Any | None = None, backend: Any | None = None, progress: bool = True, approximation: Approximation | None = None, states: StateStorage | None = None, dissipation: bool = True, run_args: dict | None = None, duration: Any | None = None, max_workers: int | None = None, ) -> "SimulationBatchResult": """Build and solve a batched sweep over pulse or sequence parameters. Parameters ---------- *axes : BatchAxis or ZippedBatchAxis Cartesian sweep axes. A :class:`ZippedBatchAxis` pairs member axes pointwise and contributes one batch dimension. tlist, solver, options, run_args, e_ops, initial_state, backend As for :meth:`simulate`; one initial state is reused for every batch point unless it is a supported mapping. progress : bool, default=True Show backend batch progress when supported. approximation : Approximation, optional Per-batch approximation override. states : {"all", "final", "none"} or None, default=None None saves all deterministic states and preserves native stochastic defaults. State storage policy for each batch result. dissipation : bool, default=True Include declared collapse channels. duration : float, optional Positive end time in ns when ``tlist`` is omitted. max_workers : int or None, default=None Worker processes for a backend that solves points in parallel, such as QuTiP. ``None`` takes the CPU count that loky reports, which honors ``LOKY_MAX_CPU_COUNT``, or fewer when the available memory cannot hold them. ``1`` solves in this process. Returns ------- SimulationBatchResult Results indexed by the Cartesian or zipped axis shape. Raises ------ ValueError If an axis is invalid, timing is inconsistent, or a sweep changes a structural quantity that cannot vary in one batch. """ with self._scoped_backend(backend): problem_batch = self.build_batch( *axes, tlist=tlist, duration=duration, solver=solver, options=options, run_args=run_args, e_ops=e_ops, initial_state=initial_state, approximation=approximation, states=states, dissipation=dissipation, ) result = self._chip.solve_many( problem_batch, progress=progress, max_workers=max_workers, ) return result
[docs] def subspace_propagator( self, devices: str | BaseDevice | Sequence[str | BaseDevice], levels: Sequence[int] = (0, 1), *, frame: str = "dressed", tlist: Any | None = None, duration: Any | None = None, solver: str | None = None, options: dict | None = None, backend: Any | None = None, approximation: Approximation | None = None, dissipation: bool | None = None, progress: bool = False, max_workers: int | None = None, ) -> "PropagatorResult": """Return the propagator of this sequence on the dressed states of a product of levels. The inputs are the labels that give each selected device one of ``levels`` and every other device level 0, in the order of ``itertools.product(levels, repeat=len(devices))``. One batch starts each input in its dressed state and projects every final state on the label target of every input, as :meth:`~quchip.results.results.SimulationResult.amplitude_array` does. The record converts the matrix to ``frame``. The dressed frame takes the dressed frequencies and the dressed ground energy from the static model of the solve, with its approximation and excitation cutoff. The solve leaves out collapse channels, so the matrix is the propagator of the Hamiltonian alone. Parameters ---------- devices : device object, str, or sequence of them Selected devices, in the order of the levels of each label. levels : sequence of int, default=(0, 1) Levels of each selected device. frame : {"dressed", "lab", "integration"}, default="dressed" Frame of the returned matrix. tlist, duration, solver, options, backend, approximation As for :meth:`simulate`. The propagator runs from ``tlist[0]`` to ``tlist[-1]``. dissipation : bool or None, default=None ``None`` leaves out the chip's collapse channels and warns when it has any. ``False`` leaves them out without a warning. ``True`` raises, because the matrix cannot include them. The result's :attr:`~quchip.results.propagator.PropagatorResult.omitted_channels` names the channels that the solve left out. progress : bool, default=False Show backend batch progress when supported. max_workers : int or None, default=None As for :meth:`simulate_batch`. Returns ------- PropagatorResult Matrix, labels, leakage of each input, frame frequencies, and the batch of solves. Raises ------ ValueError If ``dissipation`` is ``True``, ``frame`` is unknown, or a device or level is missing or repeated. """ import itertools import warnings from quchip.results.propagator import FRAMES, subspace_propagator if dissipation: raise ValueError( "subspace_propagator() returns the propagator of the Hamiltonian alone, so it cannot include the " "collapse channels. Use dissipation=False to leave them out without a warning, or simulate() for " "the evolution with them." ) if frame not in FRAMES: raise ValueError(f"frame must be one of {FRAMES}; got {frame!r}.") selected = (devices,) if isinstance(devices, (str, BaseDevice)) else tuple(devices) names = tuple(resolve_label(device) for device in selected) if not names or len(set(names)) != len(names): raise ValueError("Select at least one device, and each device once.") labels = tuple(itertools.product(tuple(levels), repeat=len(names))) if not labels or len(set(levels)) != len(tuple(levels)): raise ValueError("levels must name at least one level, and each level once.") with self._scoped_backend(backend): problem_batch = self.build_batch( self.vary("initial_state", [dict(zip(names, label)) for label in labels]), tlist=tlist, duration=duration, solver=solver, options=options, approximation=approximation, states="final", dissipation=False, ) omitted = tuple(channel.key for channel in problem_batch.problems[0].engine_result.slh.channels) if omitted and dissipation is None: warnings.warn( f"subspace_propagator() solves without the collapse channels {list(omitted)}, so the " "matrix is the propagator of the Hamiltonian alone. Pass dissipation=False to leave them " "out without this warning.", UserWarning, stacklevel=2, ) batch = self._chip.solve_many(problem_batch, progress=progress, max_workers=max_workers) return subspace_propagator(batch, names, labels, frame, omitted)
[docs] def active_patch(self, *, hops: int = 1, method: str = "sw", local: bool = False, order: Any | None = None) -> "ActivePatchResult": """Reduce the chip to this schedule's active patch (spectators eliminated). Convenience for :func:`quchip.chip.transformations.active_patch`; see it for the activity rule, elimination order, and validity reporting. Parameters ---------- hops : int, default=1 Coupling-graph expansion beyond scheduled targets. method : {"sw", "exact"}, default="sw" Reduction method forwarded to each elimination. local : bool, default=False Forwarded to each elimination. Each step then computes its generator on the patch around the eliminated device. order : sequence of devices or labels, optional Spectators to eliminate, in this order. By default, quchip chooses the order as :func:`~quchip.chip.transformations.eliminate_spectators` does. """ from quchip.chip.transformations import active_patch as _active_patch return _active_patch(self, hops=hops, method=method, local=local, order=order)
@staticmethod def _scoped_backend(backend: Any | None): """Context scoping a per-call backend override; no-op for ``None``.""" from contextlib import nullcontext if backend is None: return nullcontext() from quchip.backend import _backend_context, _coerce_backend return _backend_context(_coerce_backend(backend))
[docs] def clone(self) -> "QuantumSequence": """Return an independently editable model and pulse schedule.""" return self._copy_with_chip(self._chip.clone())
def _line_keys(self) -> tuple[tuple[str, str], ...]: """Return the ``(target, line)`` cursor keys that replay starts at zero.""" keys = list(self._removed_lines) equipment = self._chip.control_equipment for line in () if equipment is None else equipment.lines: assert line.target_label is not None keys.append((line.target_label, line.label)) return tuple(keys) def _device_labels(self) -> tuple[str, ...]: """Return the device labels whose virtual-Z frames replay tracks.""" return (*self._removed_devices, *(device.label for device in self._chip.devices)) def _copy_with_chip(self, chip: Chip) -> "QuantumSequence": """Copy authored schedule entries onto an already independent chip. Lines and devices that ``chip`` lacks keep their timing and frames in the replay of the copy. """ cloned = copy.copy(self) cloned._chip = chip cloned._entries = copy.deepcopy(self._entries) equipment = chip.control_equipment kept_lines = set() if equipment is None else {(line.target_label, line.label) for line in equipment.lines} cloned._removed_lines = tuple(key for key in self._line_keys() if key not in kept_lines) cloned._removed_devices = tuple(label for label in self._device_labels() if label not in chip.device_map) return cloned @property def parameters(self) -> Mapping[str, Any]: """Chip and scheduled-pulse values available to :meth:`with_params`.""" values = dict(self._chip.parameters) for index, entry in enumerate(self._entries): if not isinstance(entry, _PulseEntry): continue prefix = f"pulse.{index}" pulse_values = { f"{prefix}.freq": entry.freq, f"{prefix}.phase": entry.phase, f"{prefix}.start_time": entry.requested_start_time, f"{prefix}.detuning": entry.detuning, } collision = values.keys() & pulse_values.keys() if collision: raise ValueError(f"Sequence parameter paths are ambiguous: {sorted(collision)}") values.update(pulse_values) for name, value in entry.envelope.parameter_values().items(): # Carrier phase and an envelope's own phase are distinct. field = ( f"envelope.{name}" if name in _PULSE_FIELDS else name ) path = f"{prefix}.{field}" if path in values: raise ValueError(f"Sequence parameter path {path!r} is ambiguous") values[path] = value return MappingProxyType(values) @property def settings(self) -> Mapping[str, Any]: """Read-only sequence structure, separate from numerical parameters.""" return MappingProxyType( { "chip": self._chip.settings, "entries": tuple(type(entry).__name__.removeprefix("_") for entry in self._entries), } )
[docs] def with_params(self, bindings: Mapping[str, Any]) -> "QuantumSequence": """Return a cloned sequence with values rebound. Parameters ---------- bindings : mapping[str, Any] Chip parameter paths or ``pulse.<index>`` fields and new values. """ available = self.parameters unknown = set(bindings) - set(available) if unknown: raise KeyError( f"Unknown sequence parameter paths: {sorted(unknown)}. " f"Available: {list(available)}" ) pulse_bindings: dict[int, dict[str, Any]] = {} chip_bindings: dict[str, Any] = {} for path, value in bindings.items(): parts = path.split(".", 2) if len(parts) == 3 and parts[0] == "pulse" and parts[1].isdigit(): pulse_bindings.setdefault(int(parts[1]), {})[parts[2]] = value else: chip_bindings[path] = value cloned = self._copy_with_chip(self._chip.with_params(chip_bindings)) for index, updates in pulse_bindings.items(): entry = cloned._entries[index] if not isinstance(entry, _PulseEntry): # pragma: no cover - inventory excludes it raise KeyError(f"pulse.{index}") envelope_updates = { field.removeprefix("envelope."): value for field, value in updates.items() if field not in _PULSE_FIELDS } entry_updates = copy_value({ "requested_start_time" if field == "start_time" else field: value for field, value in updates.items() if field in _PULSE_FIELDS }) if envelope_updates: entry_updates["envelope"] = entry.envelope.with_params(envelope_updates) cloned._entries[index] = replace(entry, **entry_updates) return cloned
def _validate_target(self, target: str) -> None: if target in self._chip.device_map: return available = list(self._chip.device_map.keys()) hint = "" ce = self._chip.control_equipment if ce is not None: drive_labels = [d.label for d in ce.lines if d.label is not None] if target in drive_labels: hint = f" Hint: '{target}' is a drive label, not a device label. Pass the device label instead." raise ValueError(f"Device label '{target}' not found on chip. Available device labels: {available}.{hint}")
[docs] def describe(self) -> str: """Plain-text timeline of the scheduled pulses. One row per pulse shows the resolved time window, drive → device, envelope with its declared parameters, and carrier frequency, followed by a count of delays, barriers, and virtual-Z entries. A pulse whose frame differs from its device also names that frame. Returns a string: ``print(seq.describe())``. """ from quchip.chip.describe import describe_sequence return describe_sequence(self)
def __repr__(self) -> str: return f"QuantumSequence({self._chip!r}, ops={len(self._entries)}, duration={self.total_duration!r} ns)"