Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/qibocal/protocols/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
dispersive_shift,
drag,
flipping,
flipping_amplitude,
flux_dependence,
qubit_spectroscopies,
rabi,
Expand All @@ -24,6 +25,7 @@
from .dispersive_shift import *
from .drag import *
from .flipping import *
from .flipping_amplitude import *
from .flux_dependence import *
from .qubit_spectroscopies import *
from .rabi import *
Expand Down Expand Up @@ -62,6 +64,7 @@
__all__ += ["classification"]
__all__ += ["drag"]
__all__ += ["flipping"]
__all__ += ["flipping_amplitude"]
__all__ += ["readout"]
__all__ += ["tomographies"]
__all__ += ["resonator_spectroscopies"]
Expand Down
3 changes: 2 additions & 1 deletion src/qibocal/protocols/flipping.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ def flipping_sequence(
qd_pulse, amplitude=qd_pulse.amplitude + delta_amplitude
)
sequence.append((qd_channel, qd_detuned))
sequence.append((qd_channel, qd_detuned))
# sequence.append((qd_channel, qd_detuned))

if rx90:
sequence.append((qd_channel, qd_detuned))
sequence.append((qd_channel, qd_detuned))

sequence |= natives.R(theta=np.pi / 2, phi=0.0 if flips % 2 == 0 else np.pi)
Comment on lines +49 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should go back to the previous configuration

sequence |= natives.MZ()

return sequence
Expand Down
308 changes: 308 additions & 0 deletions src/qibocal/protocols/flipping_amplitude.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
"""Flipping experiment sweeping number of flips and pulse amplitude."""

from dataclasses import dataclass, field

import numpy as np
import numpy.typing as npt
import plotly.graph_objects as go
from qibolab import (
AcquisitionType,
AveragingMode,
ParallelSweepers,
Parameter,
Pulse,
PulseSequence,
Readout,
Sweeper,
)

from qibocal import update
from qibocal.auto.operation import Data, Parameters, QubitId, Results, Routine
from qibocal.calibration import CalibrationPlatform
from qibocal.protocols.utils import table_dict, table_html

__all__ = ["flipping_amplitude"]


@dataclass
class FlippingAmplitudeParameters(Parameters):
"""FlippingAmplitude runcard inputs."""

nflips_max: int = 21
"""Maximum number of flips ([RX(pi) - RX(pi)] sequences)."""
nflips_step: int = 1
"""Step size for the number of consecutive flips."""
Comment on lines +31 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as the comment below for delta_amplitude_* params.

delta_amplitude_min: float = -0.05
"""Minimum amplitude delta relative to the native pulse amplitude."""
delta_amplitude_max: float = 0.05
"""Maximum amplitude delta relative to the native pulse amplitude."""
delta_amplitude_step: float = 0.001
Comment on lines +35 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we want to gradually switch from a notation like this to a range notation, where you put as input directly the (start, end, step) tuple; from now we are still accepting both, so I think is bettter to put these params as optional, then add @property or methods that from the input they define a unique self.delta_amplitude_range and self.delta_amplitude_values to use in the _acquisition function:

Suggested change
delta_amplitude_min: float = -0.05
"""Minimum amplitude delta relative to the native pulse amplitude."""
delta_amplitude_max: float = 0.05
"""Maximum amplitude delta relative to the native pulse amplitude."""
delta_amplitude_step: float = 0.001
delta_amplitude_range: tuple[float, float, float] | None = None
"""Amplitude delta range relative to the native pulse amplitude."""
delta_amplitude_min: float | None = None
"""Minimum amplitude delta relative to the native pulse amplitude."""
delta_amplitude_max: float | None = None
"""Maximum amplitude delta relative to the native pulse amplitude."""
delta_amplitude_step: float | None = None

subsequently:

@property
def amplitude_range(self):
   if self.delta_amplitude_range is None:
         return (
                     self.delta_amplitude_start,
                     self.delta_amplitude_stop, 
                     self.delta_amplitude_step
         )
    return self.delta_amplitude_range

(BE CAREFUL, RANDOM INDENTATION)

then in the __post_init__ method you can raise an Error if self.amplitude_range is None.

"""Amplitude delta step."""
Comment on lines +31 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why this coice for the default values?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mhhh. 🥇 This is just 10% of the total range in amplitude (assuming a rabi pulse is ~ 0.3, it gives some good variability) and nflips is just a number.

rx90: bool = False
"""Calibration of native pi pulse, if true calibrates pi/2 pulse."""

def __post_init__(self):
if not isinstance(self.nflips_max, int):
raise TypeError(
f"nflips_max must be int, got {type(self.nflips_max).__name__}"
)
if not isinstance(self.nflips_step, int):
raise TypeError(
f"nflips_step must be int, got {type(self.nflips_step).__name__}"
)
if not isinstance(self.rx90, bool):
raise TypeError(f"rx90 must be boolean, got {type(self.rx90).__name__}")
Comment on lines +45 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this checks can be takes as granted or this params can be recasted

@jevillegasd jevillegasd May 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true, this I just copied from flipping but then I can make these pareameters be a subclass of FlippingParameters so that it inherits these checks.

if self.nflips_max <= 0:
raise ValueError("nflips_max must be greater than 0.")
if self.nflips_step <= 0:
raise ValueError("nflips_step must be greater than 0.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

def delta_amplitude_values(self) -> npt.NDArray:
       return np.arange(*self.delta_amplitude_range)

same for nflips (here we can also force the cast into int)


@dataclass
class FlippingAmplitudeResults(Results):
"""FlippingAmplitude outputs."""

amplitude: dict[QubitId, float | list[float]]
"""Best drive amplitude for each qubit."""
delta_amplitude: dict[QubitId, float | list[float]]
"""Difference in amplitude between native value and best fit."""
rx90: bool
"""Pi or Pi_half calibration."""


FlippingAmplitudeType = np.dtype(
[
("flips", np.float64),
("amplitude", np.float64),
("prob", np.float64),
("error", np.float64),
]
)
"""Custom dtype for flipping amplitude sweep."""


@dataclass
class FlippingAmplitudeData(Data):
"""FlippingAmplitude acquisition outputs."""

resonator_type: str
"""Resonator type."""
Comment on lines +88 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
resonator_type: str
"""Resonator type."""

in this protocol is not used at all, can be deleted everywhere

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true this was just me copying old code.

pulse_amplitudes: dict[QubitId, float]
"""Native pulse amplitudes for each qubit."""
rx90: bool
"""Pi or Pi_half calibration."""
data: dict[QubitId, npt.NDArray[FlippingAmplitudeType]] = field(
default_factory=dict
)
"""Raw data acquired."""


def _acquisition(
params: FlippingAmplitudeParameters,
platform: CalibrationPlatform,
targets: list[QubitId],
) -> FlippingAmplitudeData:
r"""Data acquisition for flipping with amplitude sweep.

For each combination of (flips, delta_amplitude) a sequence is built and
executed. The amplitude values are stored as absolute amplitudes
(native + delta). The resulting 2D map allows identifying the correct
drive amplitude: at the true pi-pulse amplitude the excited-state
probability should remain flat regardless of the number of flips.
"""

data = FlippingAmplitudeData(
resonator_type=platform.resonator_type,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
resonator_type=platform.resonator_type,

as said before

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

pulse_amplitudes={
qubit: getattr(
platform.natives.single_qubit[qubit], "RX90" if params.rx90 else "RX"
)[0][1].amplitude
for qubit in targets
},
rx90=params.rx90,
)

flips_range = range(0, params.nflips_max, params.nflips_step)
delta_amplitude_range = np.arange(
params.delta_amplitude_min,
params.delta_amplitude_max,
params.delta_amplitude_step,
)
Comment on lines +125 to +130

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with the new format no more need, simply call class property or method.


sequences: list[PulseSequence] = []
pulse_to_sweep: dict[QubitId, Pulse] = {}
pulses_store: dict[QubitId, tuple(PulseSequence, PulseSequence)] = {}

for qubit in targets:
pulses_store[qubit] = (
platform.natives.single_qubit[qubit].R(np.pi / 2),
platform.natives.single_qubit[qubit].RX90() * 4
if params.rx90
else platform.natives.single_qubit[qubit].RX() * 2,
)
pulse_to_sweep[qubit] = pulses_store[qubit][1][0][1]

for flips in flips_range:
sequence = PulseSequence()
for qubit in targets:
rx90, qd_seq = pulses_store[qubit]
sequence += (rx90 + qd_seq * flips) | platform.natives.single_qubit[
qubit
].MZ()
sequences.append(sequence)

parallel_sweepers: ParallelSweepers = [
Sweeper(
parameter=Parameter.amplitude,
values=data.pulse_amplitudes[qubit] + delta_amplitude_range,
pulses=[pulse_to_sweep[qubit]],
)
for qubit in targets
]

results = platform.execute(
sequences,
sweepers=[parallel_sweepers],
acquisition_type=AcquisitionType.DISCRIMINATION,
averaging_mode=AveragingMode.CYCLIC,
nshots=params.nshots,
relaxation_time=params.relaxation_time,
)

for flips, sequence in zip(flips_range, sequences):
for qubit, sweeper in zip(targets, parallel_sweepers):
acq_channel = platform.qubits[qubit].acquisition
assert acq_channel is not None
ro_pulse = list(sequence.channel(acq_channel))[-1]
assert isinstance(ro_pulse, Readout)
prob_array = results[ro_pulse.id]
assert len(prob_array) == len(sweeper.values)
for amp, prob in zip(sweeper.values, prob_array):
error = np.sqrt(prob * (1 - prob) / params.nshots)
data.register_qubit(
FlippingAmplitudeType,
qubit,
{
"flips": np.array([flips]),
"amplitude": np.array([amp]),
"prob": np.array([prob]),
"error": np.array([error]),
},
)

return data


def _fit(data: FlippingAmplitudeData) -> FlippingAmplitudeResults:
"""Find the best amplitude by minimising the variance of P(|1>) vs flips.
TODO: Use the same fit as in the flipping protocol to extract the rabi amp
per input delta detiuning, these should all be the same and the mean value
can be used as the best amplitude.
"""

best_amplitudes: dict[QubitId, list[float]] = {}
delta_amplitudes: dict[QubitId, list[float]] = {}

for qubit in data.qubits:
qubit_data = data[qubit]
amplitudes = np.unique(qubit_data["amplitude"])
variances = []

for amp in amplitudes:
mask = qubit_data["amplitude"] == amp
probs = qubit_data["prob"][mask]
variances.append(float(np.var(probs)))
Comment on lines +211 to +214

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't know if it is worth to investigate over more sophisticated methods such as spectral analysis or not.


best_idx = int(np.argmin(variances))
best_amp = float(amplitudes[best_idx])
native_amp = data.pulse_amplitudes[qubit]

best_amplitudes[qubit] = [best_amp, 0.0]
delta_amplitudes[qubit] = [best_amp - native_amp, 0.0]
Comment on lines +216 to +221

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

computing both best_amplitudes and delta_amplitudes is redundant, I would only use one variable, so we also delete a useless dictionary

even for the amplitude uncertainty I don't know how we can estimate it.


return FlippingAmplitudeResults(
amplitude=best_amplitudes,
delta_amplitude=delta_amplitudes,
Comment on lines +224 to +225

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deduplicate

rx90=data.rx90,
)


def _plot(
data: FlippingAmplitudeData,
target: QubitId,
fit: FlippingAmplitudeResults | None = None,
):
"""Plotting function for FlippingAmplitude.

Produces a heatmap of excited-state probability as a function of flips
(x-axis) and pulse amplitude (y-axis). When fit results are available a
dashed horizontal line marks the best amplitude.
"""

def ev(prob: np.ndarray) -> np.ndarray:
"""Helper function to calculate the expectation value."""
return 2 * prob - 1
Comment on lines +242 to +244

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why computing the expectation value of Z?

also I am pretty sure it should be: 1 - 2*prob, since prob is the probability of the qubit being in state 1 and for 1 <Z>=-1.


qubit_data = data[target]
amplitudes = np.unique(qubit_data["amplitude"])
flips_vals = np.unique(qubit_data["flips"])

# Build 2D probability matrix: rows = amplitude, cols = flips
z = np.full((len(amplitudes), len(flips_vals)), np.nan)
amp_index = {amp: i for i, amp in enumerate(amplitudes)}
flip_index = {fl: j for j, fl in enumerate(flips_vals)}

for row in qubit_data:
i = amp_index[row["amplitude"]]
j = flip_index[row["flips"]]
z[i, j] = ev(row["prob"])
Comment on lines +250 to +258

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a simple reshape should be enough:

Suggested change
# Build 2D probability matrix: rows = amplitude, cols = flips
z = np.full((len(amplitudes), len(flips_vals)), np.nan)
amp_index = {amp: i for i, amp in enumerate(amplitudes)}
flip_index = {fl: j for j, fl in enumerate(flips_vals)}
for row in qubit_data:
i = amp_index[row["amplitude"]]
j = flip_index[row["flips"]]
z[i, j] = ev(row["prob"])
z = qubit_data["prob"].reshape((len(amplitudes), len(flip_vals)))


fig = go.Figure(
go.Heatmap(
x=flips_vals,
y=amplitudes,
z=z,
colorscale="viridis",
zmid=0.0,
colorbar=dict(title="Expected value of Z"),
)
)

if fit is not None and target in fit.amplitude:
best_amp = fit.amplitude[target][0]
fig.add_hline(
y=best_amp,
line=dict(color="black", dash="dash", width=2),
annotation_text=f"Best amp: {best_amp:.4f}",
annotation_position="right",
)

fig.update_layout(
xaxis_title="Flips",
yaxis_title="Amplitude [a.u.]",
)

fitting_report = ""
if fit is not None and target in fit.amplitude:
fitting_report = table_html(
table_dict(
target,
["Best amplitude [a.u.]", "Delta amplitude [a.u.]"],
[fit.amplitude[target], fit.delta_amplitude[target]],
display_error=True,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here you are displaying a 0.0 error, maybe if we don't know how to estimate the uncertainty at the moment we can neglect displaying error.

Suggested change
display_error=True,
display_error=False,

)
)

return [fig], fitting_report


def _update(
results: FlippingAmplitudeResults,
platform: CalibrationPlatform,
qubit: QubitId,
):
update.drive_amplitude(results.amplitude[qubit], results.rx90, platform, qubit)


flipping_amplitude = Routine(_acquisition, _fit, _plot, _update)
"""FlippingAmplitude Routine object."""
Loading