Skip to content

Interop (Qiskit / PennyLane)

interop

from_qiskit

from_qiskit(circuit) -> QASMCircuit

Convert a Qiskit QuantumCircuit into a QASMCircuit via OpenQASM 2.0 (qiskit.qasm2.dumps), reusing the existing QASMParser rather than a bespoke gate-by-gate translator.

Source code in dense_evolution/interop.py
def from_qiskit(circuit) -> QASMCircuit:
    """Convert a Qiskit QuantumCircuit into a QASMCircuit via OpenQASM 2.0
    (qiskit.qasm2.dumps), reusing the existing QASMParser rather than a
    bespoke gate-by-gate translator."""
    _require_qiskit()
    qasm_str = _qasm2.dumps(circuit)
    return QASMParser().parse(qasm_str)

from_pennylane

from_pennylane(circuit, *args, **kwargs) -> QASMCircuit

Convert a PennyLane QNode or QuantumTape/QuantumScript into a QASMCircuit via OpenQASM 2.0, reusing the existing QASMParser.

PennyLane's own serialization API for a bare tape has changed across versions in an incompatible way (verified directly against both): - >=~0.43 (Python 3.11+ only): qml.to_openqasm(tape) returns the QASM string directly; QuantumTape/QuantumScript no longer has a to_openqasm() method at all. - <=0.42.x (still installed on Python 3.10, where newer PennyLane isn't available): qml.to_openqasm(tape) does NOT special-case a bare tape — it returns a QNode-oriented wrapper that crashes with AttributeError ('QuantumTape' object has no attribute 'func') if called on one. The tape's own tape.to_openqasm() method is what works there instead. So: a bare tape/QuantumScript uses its own to_openqasm() method when present (old API), otherwise falls through to the top-level qml.to_openqasm() (new API). A QNode (not a QuantumScript instance) always uses the top-level function, which returns a wrapper that must be called with the QNode's own arguments — consistent across both versions, this path was never the one that broke.

WIRE ORDER: by default, both PennyLane APIs number the exported QASM qubits in the order wires are FIRST TOUCHED in the circuit, not by their actual wire index — e.g. qml.PauliX(wires=2) followed by qml.CNOT(wires=[2, 1]) becomes x q[0]; cx q[0],q[1]; in the default export, silently renumbering wire 2 -> q[0] and wire 1 -> q[1]. Verified directly: this produced a topologically different circuit from the one PennyLane itself executes whenever wires aren't touched in ascending order (a QASMParser-based bridge has no way to recover the true mapping after the fact — the touch-order renumbering has already happened by the time QASM text exists). Both APIs accept an explicit wires= argument that forces the true wire order into the export instead — used here for both the QNode path (the device's own declared wire order) and the tape path (the tape's own wires, sorted ascending, since a bare tape has no device to ask).

Source code in dense_evolution/interop.py
def from_pennylane(circuit, *args, **kwargs) -> QASMCircuit:
    """Convert a PennyLane QNode or QuantumTape/QuantumScript into a
    QASMCircuit via OpenQASM 2.0, reusing the existing QASMParser.

    PennyLane's own serialization API for a bare tape has changed across
    versions in an incompatible way (verified directly against both):
      - >=~0.43 (Python 3.11+ only): qml.to_openqasm(tape) returns the
        QASM string directly; QuantumTape/QuantumScript no longer has a
        to_openqasm() method at all.
      - <=0.42.x (still installed on Python 3.10, where newer PennyLane
        isn't available): qml.to_openqasm(tape) does NOT special-case a
        bare tape — it returns a QNode-oriented wrapper that crashes with
        AttributeError ('QuantumTape' object has no attribute 'func') if
        called on one. The tape's own tape.to_openqasm() method is what
        works there instead.
    So: a bare tape/QuantumScript uses its own to_openqasm() method when
    present (old API), otherwise falls through to the top-level
    qml.to_openqasm() (new API). A QNode (not a QuantumScript instance)
    always uses the top-level function, which returns a wrapper that must
    be called with the QNode's own arguments — consistent across both
    versions, this path was never the one that broke.

    WIRE ORDER: by default, both PennyLane APIs number the exported QASM
    qubits in the order wires are FIRST TOUCHED in the circuit, not by
    their actual wire index — e.g. `qml.PauliX(wires=2)` followed by
    `qml.CNOT(wires=[2, 1])` becomes `x q[0]; cx q[0],q[1];` in the
    default export, silently renumbering wire 2 -> q[0] and wire 1 -> q[1].
    Verified directly: this produced a topologically different circuit
    from the one PennyLane itself executes whenever wires aren't touched
    in ascending order (a QASMParser-based bridge has no way to recover
    the true mapping after the fact — the touch-order renumbering has
    already happened by the time QASM text exists). Both APIs accept an
    explicit `wires=` argument that forces the true wire order into the
    export instead — used here for both the QNode path (the device's own
    declared wire order) and the tape path (the tape's own wires, sorted
    ascending, since a bare tape has no device to ask).
    """
    _require_pennylane()
    if isinstance(circuit, qml.tape.QuantumScript):
        wires = _sorted_wires(circuit.wires)
        if hasattr(circuit, 'to_openqasm'):
            qasm_str = circuit.to_openqasm(wires=wires, measure_all=False)
        else:
            qasm_str = qml.to_openqasm(circuit, wires=wires, measure_all=False)
    else:
        device = getattr(circuit, 'device', None)
        wires = device.wires if device is not None else None
        result = qml.to_openqasm(circuit, wires=wires, measure_all=False)
        qasm_str = result if isinstance(result, str) else result(*args, **kwargs)
    return QASMParser().parse(qasm_str)

run_qiskit_circuit

run_qiskit_circuit(
    circuit,
    use_float32: bool = True,
    sim: Optional[DenseSVSimulator] = None,
) -> Tuple[DenseSVSimulator, np.ndarray]

Run a Qiskit QuantumCircuit on DenseSVSimulator. Returns (sim, probabilities) with probabilities reordered into Qiskit's own little-endian bit convention, so they compare directly against Statevector(circuit).probabilities() — see _to_qiskit_bit_order.

Source code in dense_evolution/interop.py
def run_qiskit_circuit(
    circuit,
    use_float32: bool = True,
    sim: Optional[DenseSVSimulator] = None,
) -> Tuple[DenseSVSimulator, np.ndarray]:
    """Run a Qiskit QuantumCircuit on DenseSVSimulator. Returns
    (sim, probabilities) with probabilities reordered into Qiskit's own
    little-endian bit convention, so they compare directly against
    Statevector(circuit).probabilities() — see _to_qiskit_bit_order."""
    circ = from_qiskit(circuit)
    if sim is None:
        sim = DenseSVSimulator(n_qubits=circ.n_qubits, use_float32=use_float32)
    sim.run_circuit(circ.to_tuples())
    probs = np.asarray(sim.get_probabilities())
    return sim, _to_qiskit_bit_order(probs, circ.n_qubits)

run_pennylane_circuit

run_pennylane_circuit(
    circuit,
    *args,
    use_float32: bool = True,
    sim: Optional[DenseSVSimulator] = None,
    **kwargs,
) -> Tuple[DenseSVSimulator, np.ndarray]

Run a PennyLane QNode/tape on DenseSVSimulator. Returns (sim, probabilities) in Dense-Evolution's native ordering, WITHOUT any bit-reversal — unlike run_qiskit_circuit, because PennyLane's own wire convention (wire 0 = most significant) already matches Dense-Evolution's MSB-first convention. Do not "symmetrize" this with the Qiskit version; that would silently misorder circuits that are asymmetric under qubit reversal (verified directly: no permutation needed here, one is required for Qiskit — the two frameworks are genuinely different).

NOT DIFFERENTIABLE: from_pennylane() bakes every gate parameter into a plain Python float inside the QASM text, so it leaves the JAX trace. jax.grad through this function does not raise — it silently returns 0.0 (verified), which reads as "converged" rather than "not wired up". For a real gradient through a Dense-Evolution circuit, use the dashboard_core._vqe_energy_fn pattern instead (jax.value_and_grad over a jax.lax.scan template with sentinel-injected parameters).

Source code in dense_evolution/interop.py
def run_pennylane_circuit(
    circuit,
    *args,
    use_float32: bool = True,
    sim: Optional[DenseSVSimulator] = None,
    **kwargs,
) -> Tuple[DenseSVSimulator, np.ndarray]:
    """Run a PennyLane QNode/tape on DenseSVSimulator. Returns
    (sim, probabilities) in Dense-Evolution's native ordering, WITHOUT any
    bit-reversal — unlike run_qiskit_circuit, because PennyLane's own wire
    convention (wire 0 = most significant) already matches Dense-Evolution's
    MSB-first convention. Do not "symmetrize" this with the Qiskit version;
    that would silently misorder circuits that are asymmetric under qubit
    reversal (verified directly: no permutation needed here, one is
    required for Qiskit — the two frameworks are genuinely different).

    NOT DIFFERENTIABLE: from_pennylane() bakes every gate parameter into a
    plain Python float inside the QASM text, so it leaves the JAX trace.
    jax.grad through this function does not raise — it silently returns
    0.0 (verified), which reads as "converged" rather than "not wired up".
    For a real gradient through a Dense-Evolution circuit, use the
    dashboard_core._vqe_energy_fn pattern instead (jax.value_and_grad over
    a jax.lax.scan template with sentinel-injected parameters)."""
    circ = from_pennylane(circuit, *args, **kwargs)
    if sim is None:
        sim = DenseSVSimulator(n_qubits=circ.n_qubits, use_float32=use_float32)
    sim.run_circuit(circ.to_tuples())
    probs = np.asarray(sim.get_probabilities())
    return sim, probs