Skip to content

Test a Program against a hardware profile

A hardware-profile simulator checks whether a Program obeys a selected device's rules as written. Like the general Simulator, it applies circuit operations and can include noise channels. It also enforces a native operation set, placement, connectivity, capacity, and, for atom arrays, occupancy.

Use a profile to test choices such as which operations to use and where to place program qubits. The profile validates those choices; you supply the Program and layout.

Compare circuit behavior

The general-purpose simulator can run this Bell Program, which uses H and CX without specifying device placement:

>>> import numpy as np
>>> import fatqat as fq
>>> import fatqat.operations as ops
>>> bell = fq.Program(2, 2)
>>> bell.add(ops.H, 0)
>>> bell.add(ops.CX, (0, 1))
>>> bell.measure_all()
>>> counts = fq.simulator.Simulator(runtime="numpy").run(
...     bell,
...     shots=16,
...     simulation_config={"seed": 7},
... ).result().get_counts()
>>> sum(counts.values())
16
>>> set(counts) <= {"00", "11"}
True

Now ask the constrained superconducting profile about the same Program. The profile can report whether it supports H, so you do not need to copy its gate table into application code:

>>> profile = fq.simulator.SCQubitSimulator(
...     num_qubits=6,
...     couplings=((0, 1), (1, 2), (3, 4), (4, 5),
...                (0, 3), (1, 4), (2, 5)),
...     runtime="numpy",
... )
>>> profile.implementation_map.supports(ops.H)
False

Submitting bell would therefore raise UnsupportedOperationError. FatQat does not silently decompose H or CX into this profile's native operations.

Make placement explicit

Native operations are only half the question. On this 2 x 3 profile, integer device labels are arranged row by row:

0 --- 1 --- 2
|     |     |
3 --- 4 --- 5

The next Program is native, but placing its two qubits at 0 and 4 asks for a diagonal CZ that the grid does not provide:

>>> qubits = fq.QuantumRegister(2, name="q")
>>> native = fq.Program([qubits])
>>> native.add(ops.X, qubits[0])
>>> native.add(ops.X, qubits[1])
>>> native.add(ops.CZ, (qubits[0], qubits[1]))
>>> bad_layout = fq.ResourceLayout({qubits[0]: 0, qubits[1]: 4})
>>> try:
...     profile.run(native, resource_layout=bad_layout)
... except fq.errors.UnsupportedOperationError as error:
...     print(error)
CZGate is not supported on device operands (0, 4)

Move the second program qubit to the neighbouring device label 1; the Program itself does not need to change:

>>> layout = fq.ResourceLayout({qubits[0]: 0, qubits[1]: 1})
>>> state = profile.run(native, resource_layout=layout).result().get_statevector()
>>> state.shape
(4,)
>>> int(np.argmax(np.abs(state) ** 2))
3

This confirms that the operation set and placement are valid. The profile can also apply noise channels, as the next example shows. Use a physical emulator when the question depends on pulse shapes or how the state evolves during a control.

Add reference noise deliberately

The superconducting profile is ideal unless a noise model is passed. Its packaged model is a useful comparison baseline, not a current hardware characterization:

profile_type = fq.simulator.SCQubitSimulator
noisy_profile = profile_type(
    num_qubits=6,
    couplings=((0, 1), (1, 2), (3, 4), (4, 5),
               (0, 3), (1, 4), (2, 5)),
    runtime="numpy",
    noise=profile_type.default_noise_model(),
)

measured_native = fq.Program(2, 2)
measured_native.add(ops.X, 0)
measured_native.add(ops.X, 1)
measured_native.add(ops.CZ, (0, 1))
measured_native.measure_all()
noisy_counts = noisy_profile.run(
    measured_native,
    shots=100,
    simulation_config={"seed": 7},
).result().get_counts()

Compare runs with and without this model to study its effect on the output. AtomArraySimulator has no packaged reference noise model; pass a NoiseModel of your own when loading, loss, or other effects belong in the experiment.

Track atom occupancy and pairing

Unlike the superconducting profile, the atom array has no fixed geometry. Program resources define sites that begin empty. Put loads the atoms, while Pair and Unpair reshape the connectivity on which CZ is legal:

Two occupied atoms begin separated, move together when Pair applies with depolarizing noise, remain paired for CZ, and separate again when Unpair applies with depolarizing noise.

The changing distance is a picture of the pairing intent, not a simulated trajectory. AtomArraySimulator records no coordinates or movement duration; Pair declares that the two occupied sites may execute native CZ, and Unpair removes that eligibility.

>>> atoms = fq.Program(2, 2)
>>> atoms.add(ops.Put, (0, 1))
>>> atoms.add(ops.Pair, (0, 1))
>>> atoms.add(ops.RX(np.pi), 0)
>>> atoms.add(ops.CZ, (0, 1))
>>> atoms.add(ops.Unpair, (0, 1))
>>> atoms.measure_all()
>>> atom_counts = fq.simulator.AtomArraySimulator().run(
...     atoms,
...     shots=8,
...     simulation_config={"seed": 7},
... ).result().get_counts()
>>> atom_counts
{'10': 8}

Pairing is ideal unless you attach a noise assumption. For example, apply a small depolarizing channel independently to each atom whenever either movement instruction occurs:

>>> movement_noise = fq.NoiseModel()
>>> for movement in (ops.Pair, ops.Unpair):
...     for target_position in (0, 1):
...         movement_noise.add(
...             fq.noise.Depolarizing(p=0.02),
...             operation=movement,
...             target_positions=target_position,
...         )
>>> noisy_atom_backend = fq.simulator.AtomArraySimulator(
...     noise=movement_noise,
... )
>>> noisy_counts = noisy_atom_backend.run(
...     atoms,
...     shots=100,
...     simulation_config={"seed": 7},
... ).result().get_counts()
>>> sum(noisy_counts.values())
100

This channel perturbs the quantum state during Pair and Unpair; it does not remove either atom. Use Loss instead when movement should change occupancy.

An occupied atom undergoes a matched operation, after which Loss either leaves it present with probability one minus p or removes it with probability p; measurement of the empty site returns 2.

Loss can be attached to any supported operation. Here it is sampled after RX, so surviving atoms return 1, while lost atoms return 2:

>>> loss_model = fq.NoiseModel()
>>> loss_model.add(fq.noise.Loss(p=0.1), operation=ops.RX)
>>> lossy_atoms = fq.Program(1, 1)
>>> lossy_atoms.add(ops.Put, 0)
>>> lossy_atoms.add(ops.RX(np.pi), 0)
>>> lossy_atoms.measure_all()
>>> lossy_counts = fq.simulator.AtomArraySimulator(noise=loss_model).run(
...     lossy_atoms,
...     shots=100,
...     simulation_config={"seed": 7},
... ).result().get_counts()
>>> lossy_counts
{'1': 86, '2': 14}
Omitting Pair before CZ is a program error; FatQat does not transport or pair atoms automatically. A missing atom is different: supported gates find nothing to act on, and measurement reports the erasure digit 2.

For native gates, program sizing, and method support, use the hardware-profile API. For pulse duration, physical levels, drift, or continuous-time noise, see Hamiltonian emulation.