def test_fixed_param_is_missing(self):
        """Test raising an analysis error when fixed parameter is missing."""
        analysis = create_new_analysis(
            series=[
                SeriesDef(
                    name="curve1",
                    fit_func=lambda x, p0, p1, fixed_p2, p3: fit_function.cos(
                        x, amp=p0, freq=p1, phase=fixed_p2, baseline=p3),
                ),
            ],
            fixed_params=["fixed_p2"],
        )

        ref_p0 = 0.1
        ref_p1 = 2
        ref_p2 = -0.3
        ref_p3 = 0.5

        test_data = simulate_output_data(
            func=fit_function.cos,
            xvals=self.xvalues,
            param_dict={
                "amp": ref_p0,
                "freq": ref_p1,
                "phase": ref_p2,
                "baseline": ref_p3
            },
        )
        # do not define fixed_p2 here
        analysis.set_options(p0={"p0": ref_p0, "p1": ref_p1, "p3": ref_p3})
        with self.assertRaises(AnalysisError):
            analysis._run_analysis(test_data)
    def test_run_fixed_parameters(self):
        """Test analysis when some of parameters are fixed."""
        analysis = create_new_analysis(
            series=[
                SeriesDef(
                    name="curve1",
                    fit_func=lambda x, par0, par1, fixed_par2, par3:
                    fit_function.cos(x,
                                     amp=par0,
                                     freq=par1,
                                     phase=fixed_par2,
                                     baseline=par3),
                ),
            ],
            fixed_params=["fixed_par2"],
        )

        ref_p0 = 0.1
        ref_p1 = 2
        ref_p2 = -0.3
        ref_p3 = 0.5

        test_data = simulate_output_data(
            func=fit_function.cos,
            xvals=self.xvalues,
            param_dict={
                "amp": ref_p0,
                "freq": ref_p1,
                "phase": ref_p2,
                "baseline": ref_p3
            },
        )

        analysis.set_options(
            p0={
                "par0": ref_p0,
                "par1": ref_p1,
                "par3": ref_p3
            },
            fixed_parameters={"fixed_par2": ref_p2},
        )

        results, _ = analysis._run_analysis(test_data)
        result = results[0]

        ref_popt = np.asarray([ref_p0, ref_p1, ref_p3])

        # check result data
        np.testing.assert_array_almost_equal(result.value,
                                             ref_popt,
                                             decimal=self.err_decimal)
Exemple #3
0
class DragCalAnalysis(curve.CurveAnalysis):
    r"""Drag calibration analysis based on a fit to a cosine function.

    # section: fit_model

        Analyse a Drag calibration experiment by fitting three series each to a cosine function.
        The three functions share the phase parameter (i.e. beta) but each have their own amplitude,
        baseline, and frequency parameters (which therefore depend on the number of repetitions of
        xp-xm). Several initial guesses are tried if the user does not provide one.

        .. math::

            y_i = {\rm amp} \cos\left(2 \pi\cdot {\rm freq}_i\cdot x -
            2 \pi\cdot {\rm freq}_i\cdot \beta\right) + {\rm base}

        Note that the aim of the Drag calibration is to find the :math:`\beta` that minimizes the
        phase shifts. This implies that the optimal :math:`\beta` occurs when all three :math:`y`
        curves are minimum, i.e. they produce the ground state. Therefore,

        .. math::

            y_i = 0 \quad \Longrightarrow \quad -{\rm amp} \cos(2 \pi\cdot X_i) = {\rm base}

        Here, we abbreviated :math:`{\rm freq}_i\cdot x - {\rm freq}_i\cdot \beta` by :math:`X_i`.
        For a signal between 0 and 1 the :math:`{\rm base}` will typically fit to 0.5. However, the
        equation has an ambiguity if the amplitude is not properly bounded. Indeed,

        - if :math:`{\rm amp} < 0` then we require :math:`2 \pi\cdot X_i = 0` mod :math:`2\pi`, and
        - if :math:`{\rm amp} > 0` then we require :math:`2 \pi\cdot X_i = \pi` mod :math:`2\pi`.

        This will result in an ambiguity in :math:`\beta` which we avoid by bounding the amplitude
        from above by 0.

    # section: fit_parameters
        defpar \rm amp:
            desc: Amplitude of all series.
            init_guess: The maximum y value less the minimum y value. 0.5 is also tried.
            bounds: [-2, 2] scaled to the maximum signal value.

        defpar \rm base:
            desc: Base line of all series.
            init_guess: The average of the data. 0.5 is also tried.
            bounds: [-1, 1] scaled to the maximum signal value.

        defpar {\rm freq}_i:
            desc: Frequency of the :math:`i` th oscillation.
            init_guess: The frequency with the highest power spectral density.
            bounds: [0, inf].

        defpar \beta:
            desc: Common beta offset. This is the parameter of interest.
            init_guess: Linearly spaced between the maximum and minimum scanned beta.
            bounds: [-min scan range, max scan range].
    """

    __series__ = [
        curve.SeriesDef(
            fit_func=lambda x, amp, freq0, freq1, freq2, beta, base: cos(
                x, amp=amp, freq=freq0, phase=-2 * np.pi * freq0 * beta, baseline=base
            ),
            plot_color="blue",
            name="series-0",
            filter_kwargs={"series": 0},
            plot_symbol="o",
            model_description=r"{\rm amp} \cos\left(2 \pi\cdot {\rm freq}_0\cdot x "
            r"- 2 \pi\cdot {\rm freq}_0\cdot \beta\right) + {\rm base}",
        ),
        curve.SeriesDef(
            fit_func=lambda x, amp, freq0, freq1, freq2, beta, base: cos(
                x, amp=amp, freq=freq1, phase=-2 * np.pi * freq1 * beta, baseline=base
            ),
            plot_color="green",
            name="series-1",
            filter_kwargs={"series": 1},
            plot_symbol="^",
            model_description=r"{\rm amp} \cos\left(2 \pi\cdot {\rm freq}_1\cdot x "
            r"- 2 \pi\cdot {\rm freq}_1\cdot \beta\right) + {\rm base}",
        ),
        curve.SeriesDef(
            fit_func=lambda x, amp, freq0, freq1, freq2, beta, base: cos(
                x, amp=amp, freq=freq2, phase=-2 * np.pi * freq2 * beta, baseline=base
            ),
            plot_color="red",
            name="series-2",
            filter_kwargs={"series": 2},
            plot_symbol="v",
            model_description=r"{\rm amp} \cos\left(2 \pi\cdot {\rm freq}_2\cdot x "
            r"- 2 \pi\cdot {\rm freq}_2\cdot \beta\right) + {\rm base}",
        ),
    ]

    @classmethod
    def _default_options(cls):
        """Return the default analysis options.

        See :meth:`~qiskit_experiment.curve_analysis.CurveAnalysis._default_options` for
        descriptions of analysis options.
        """
        default_options = super()._default_options()
        default_options.result_parameters = ["beta"]
        default_options.xlabel = "Beta"
        default_options.ylabel = "Signal (arb. units)"

        return default_options

    def _generate_fit_guesses(
        self, user_opt: curve.FitOptions
    ) -> Union[curve.FitOptions, List[curve.FitOptions]]:
        """Compute the initial guesses.

        Args:
            user_opt: Fit options filled with user provided guess and bounds.

        Returns:
            List of fit options that are passed to the fitter function.
        """
        # Use a fast Fourier transform to guess the frequency.
        x_data = self._data("series-0").x
        min_beta, max_beta = min(x_data), max(x_data)

        freqs_guesses = {}
        for i in range(3):
            curve_data = self._data(f"series-{i}")
            freqs_guesses[f"freq{i}"] = curve.guess.frequency(curve_data.x, curve_data.y)
        user_opt.p0.set_if_empty(**freqs_guesses)

        max_abs_y, _ = curve.guess.max_height(self._data().y, absolute=True)
        freq_bound = max(10 / user_opt.p0["freq0"], max(x_data))

        user_opt.bounds.set_if_empty(
            amp=(-2 * max_abs_y, 0),
            freq0=(0, np.inf),
            freq1=(0, np.inf),
            freq2=(0, np.inf),
            beta=(-freq_bound, freq_bound),
            base=(-max_abs_y, max_abs_y),
        )
        user_opt.p0.set_if_empty(base=0.5)

        # Drag curves can sometimes be very flat, i.e. averages of y-data
        # and min-max do not always make good initial guesses. We therefore add
        # 0.5 to the initial guesses. Note that we also set amp=-0.5 because the cosine function
        # becomes +1 at zero phase, i.e. optimal beta, in which y data should become zero
        # in discriminated measurement level.
        options = []
        for amp_guess in (0.5, -0.5):
            for beta_guess in np.linspace(min_beta, max_beta, 20):
                new_opt = user_opt.copy()
                new_opt.p0.set_if_empty(amp=amp_guess, beta=beta_guess)
                options.append(new_opt)

        return options

    def _evaluate_quality(self, fit_data: curve.FitData) -> Union[str, None]:
        """Algorithmic criteria for whether the fit is good or bad.

        A good fit has:
            - a reduced chi-squared lower than three,
            - a DRAG parameter value within the first period of the lowest number of repetitions,
            - an error on the drag beta smaller than the beta.
        """
        fit_beta = fit_data.fitval("beta").value
        fit_beta_err = fit_data.fitval("beta").stderr
        fit_freq0 = fit_data.fitval("freq0").value

        criteria = [
            fit_data.reduced_chisq < 3,
            fit_beta < 1 / fit_freq0,
            fit_beta_err < abs(fit_beta),
        ]

        if all(criteria):
            return "good"

        return "bad"
class DragCalAnalysis(curve.CurveAnalysis):
    r"""Drag calibration analysis based on a fit to a cosine function.

    # section: fit_model

        Analyse a Drag calibration experiment by fitting three series each to a cosine
        function. The three functions share the phase parameter (i.e. beta), amplitude, and
        baseline. The frequencies of the oscillations are related through the number of
        repetitions of the Drag gates. Several initial guesses are tried if the user
        does not provide one. The fit function is

        .. math::

            y_i = {\rm amp} \cos\left(2 \pi\cdot {\rm reps}_i \cdot {\rm freq}\cdot x -
            2 \pi\cdot {\rm reps}_i \cdot {\rm freq}\cdot \beta\right) + {\rm base}

        Here, the fit parameter :math:`freq` is the frequency of the oscillation of a
        single pair of Drag plus and minus rotations and :math:`{\rm reps}_i` is the number
        of times that the Drag plus and minus rotations are repeated in curve :math:`i`.
        Note that the aim of the Drag calibration is to find the :math:`\beta` that
        minimizes the phase shifts. This implies that the optimal :math:`\beta` occurs when
        all three :math:`y` curves are minimum, i.e. they produce the ground state. This
        occurs when

        .. math::

            {\rm reps}_i * {\rm freq} * (x - \beta) = N

        is satisfied with :math:`N` an integer. Note, however, that this condition
        produces a minimum only when the amplitude is negative. To ensure this is
        the case, we bound the amplitude to be less than 0.

    # section: fit_parameters
        defpar \rm amp:
            desc: Amplitude of all series.
            init_guess: The maximum y value scaled by -1, -0.5, and -0.25.
            bounds: [-2, 0] scaled to the maximum signal value.

        defpar \rm base:
            desc: Base line of all series.
            init_guess: Half the maximum y-value of the data.
            bounds: [-1, 1] scaled to the maximum y-value.

        defpar {\rm freq}:
            desc: Frequency of oscillation as a function of :math:`\beta` for a single pair
                of DRAG plus and minus pulses.
            init_guess: For the curve with the most Drag pulse repetitions, the peak frequency
                of the power spectral density is found and then divided by the number of repetitions.
            bounds: [0, inf].

        defpar \beta:
            desc: Common beta offset. This is the parameter of interest.
            init_guess: Linearly spaced between the maximum and minimum scanned beta.
            bounds: [-min scan range, max scan range].
    """

    __series__ = [
        curve.SeriesDef(
            fit_func=lambda x, amp, freq, reps0, reps1, reps2, beta, base: cos(
                x,
                amp=amp,
                freq=reps0 * freq,
                phase=-2 * np.pi * reps0 * freq * beta,
                baseline=base),
            plot_color="blue",
            name="series-0",
            filter_kwargs={"series": 0},
            plot_symbol="o",
            model_description=
            r"{\rm amp} \cos\left(2 \pi\cdot {\rm reps}_0\cdot {\rm freq} [x "
            r"- \beta]\right) + {\rm base}",
        ),
        curve.SeriesDef(
            fit_func=lambda x, amp, freq, reps0, reps1, reps2, beta, base: cos(
                x,
                amp=amp,
                freq=reps1 * freq,
                phase=-2 * np.pi * reps1 * freq * beta,
                baseline=base),
            plot_color="green",
            name="series-1",
            filter_kwargs={"series": 1},
            plot_symbol="^",
            model_description=
            r"{\rm amp} \cos\left(2 \pi\cdot {\rm reps}_1\cdot {\rm freq} [x "
            r"- \beta]\right) + {\rm base}",
        ),
        curve.SeriesDef(
            fit_func=lambda x, amp, freq, reps0, reps1, reps2, beta, base: cos(
                x,
                amp=amp,
                freq=reps2 * freq,
                phase=-2 * np.pi * reps2 * freq * beta,
                baseline=base),
            plot_color="red",
            name="series-2",
            filter_kwargs={"series": 2},
            plot_symbol="v",
            model_description=
            r"{\rm amp} \cos\left(2 \pi\cdot {\rm reps}_2\cdot {\rm freq} [x "
            r"- \beta]\right) + {\rm base}",
        ),
    ]

    @classmethod
    def _default_options(cls):
        """Return the default analysis options.

        See :meth:`~qiskit_experiment.curve_analysis.CurveAnalysis._default_options` for
        descriptions of analysis options.
        """
        default_options = super()._default_options()
        default_options.curve_drawer.set_options(
            xlabel="Beta",
            ylabel="Signal (arb. units)",
        )
        default_options.result_parameters = ["beta"]
        default_options.fixed_parameters = {"reps0": 1, "reps1": 3, "reps2": 5}
        default_options.normalization = True

        return default_options

    def _generate_fit_guesses(
        self,
        user_opt: curve.FitOptions,
        curve_data: curve.CurveData,
    ) -> Union[curve.FitOptions, List[curve.FitOptions]]:
        """Create algorithmic guess with analysis options and curve data.

        Args:
            user_opt: Fit options filled with user provided guess and bounds.
            curve_data: Formatted data collection to fit.

        Returns:
            List of fit options that are passed to the fitter function.
        """
        # Use a fast Fourier transform to guess the frequency.
        x_data = curve_data.get_subset_of("series-0").x
        min_beta, max_beta = min(x_data), max(x_data)

        # Use the highest-frequency curve to estimate the oscillation frequency.
        series_label, reps_label = max(
            ("series-0", "reps0"),
            ("series-1", "reps1"),
            ("series-2", "reps2"),
            key=lambda x: self.options.fixed_parameters[x[1]],
        )
        curve_data = curve_data.get_subset_of(series_label)
        reps2 = self.options.fixed_parameters[reps_label]
        freqs_guess = curve.guess.frequency(curve_data.x, curve_data.y) / reps2
        user_opt.p0.set_if_empty(freq=freqs_guess)

        avg_x = (max(x_data) + min(x_data)) / 2
        span_x = max(x_data) - min(x_data)
        beta_bound = max(5 / user_opt.p0["freq"], span_x)

        ptp_y = np.ptp(curve_data.y)
        user_opt.bounds.set_if_empty(
            amp=(-2 * ptp_y, 0),
            freq=(0, np.inf),
            beta=(avg_x - beta_bound, avg_x + beta_bound),
            base=(min(curve_data.y) - ptp_y, max(curve_data.y) + ptp_y),
        )
        base_guess = (max(curve_data.y) - min(curve_data.y)) / 2
        user_opt.p0.set_if_empty(base=(user_opt.p0["amp"] or base_guess))

        # Drag curves can sometimes be very flat, i.e. averages of y-data
        # and min-max do not always make good initial guesses. We therefore add
        # 0.5 to the initial guesses. Note that we also set amp=-0.5 because the cosine function
        # becomes +1 at zero phase, i.e. optimal beta, in which y data should become zero
        # in discriminated measurement level.
        options = []
        for amp_factor in (-1, -0.5, -0.25):
            for beta_guess in np.linspace(min_beta, max_beta, 20):
                new_opt = user_opt.copy()
                new_opt.p0.set_if_empty(amp=ptp_y * amp_factor,
                                        beta=beta_guess)
                options.append(new_opt)

        return options

    def _run_curve_fit(
        self,
        curve_data: curve.CurveData,
        series: List[curve.SeriesDef],
    ) -> Union[None, curve.FitData]:
        r"""Perform curve fitting on given data collection and fit models.

        .. note::

            This class post-processes the fit result from a Drag analysis.

            The Drag analysis should return the beta value that is closest to zero.
            Since the oscillating term is of the form

            .. math::

                \cos(2 \pi\cdot {\rm reps}_i \cdot {\rm freq}\cdot [x - \beta])

            There is a periodicity in beta. This post processing finds the beta that is
            closest to zero by performing the minimization using the modulo function.

            .. math::

                n_\text{min} = \min_{n}|\beta_\text{fit} + n / {\rm freq}|

            and assigning the new beta value to

            .. math::

                \beta = \beta_\text{fit} + n_\text{min} / {\rm freq}.

        Args:
            curve_data: Formatted data to fit.
            series: A list of fit models.

        Returns:
            The best fitting outcome with minimum reduced chi-squared value.
        """
        fit_result = super()._run_curve_fit(curve_data, series)
        beta = fit_result.popt[2]
        freq = fit_result.popt[1]
        fit_result.popt[2] = ((beta + 1 / freq / 2) %
                              (1 / freq)) - 1 / freq / 2

        return fit_result

    def _evaluate_quality(self, fit_data: curve.FitData) -> Union[str, None]:
        """Algorithmic criteria for whether the fit is good or bad.

        A good fit has:
            - a reduced chi-squared lower than three,
            - a DRAG parameter value within the first period of the lowest number of repetitions,
            - an error on the drag beta smaller than the beta.
        """
        fit_beta = fit_data.fitval("beta")
        fit_freq = fit_data.fitval("freq")

        criteria = [
            fit_data.reduced_chisq < 3,
            abs(fit_beta.nominal_value) < 1 / fit_freq.nominal_value / 2,
            curve.is_error_not_significant(fit_beta),
        ]

        if all(criteria):
            return "good"

        return "bad"
    def test_run_two_curves_with_two_fitfuncs(self):
        """Test analysis for two curves. Curves shares fit parameters."""
        analysis = create_new_analysis(series=[
            SeriesDef(
                name="curve1",
                fit_func=lambda x, p0, p1, p2, p3: fit_function.cos(
                    x, amp=p0, freq=p1, phase=p2, baseline=p3),
                filter_kwargs={"exp": 0},
            ),
            SeriesDef(
                name="curve2",
                fit_func=lambda x, p0, p1, p2, p3: fit_function.sin(
                    x, amp=p0, freq=p1, phase=p2, baseline=p3),
                filter_kwargs={"exp": 1},
            ),
        ], )
        ref_p0 = 0.1
        ref_p1 = 2
        ref_p2 = -0.3
        ref_p3 = 0.5

        test_data0 = simulate_output_data(
            func=fit_function.cos,
            xvals=self.xvalues,
            param_dict={
                "amp": ref_p0,
                "freq": ref_p1,
                "phase": ref_p2,
                "baseline": ref_p3
            },
            exp=0,
        )

        test_data1 = simulate_output_data(
            func=fit_function.sin,
            xvals=self.xvalues,
            param_dict={
                "amp": ref_p0,
                "freq": ref_p1,
                "phase": ref_p2,
                "baseline": ref_p3
            },
            exp=1,
        )

        # merge two experiment data
        for datum in test_data1.data():
            test_data0.add_data(datum)

        analysis.set_options(p0={
            "p0": ref_p0,
            "p1": ref_p1,
            "p2": ref_p2,
            "p3": ref_p3
        })
        results, _ = analysis._run_analysis(test_data0)
        result = results[0]

        ref_popt = np.asarray([ref_p0, ref_p1, ref_p2, ref_p3])

        # check result data
        np.testing.assert_array_almost_equal(result.value.value,
                                             ref_popt,
                                             decimal=self.err_decimal)