Repository: qiboteam/qibolab
Pull request #1168: Vectorize emulator results handling
Author: login: alecandido
Created: 2025-03-07T18:30:32Z | Merged: 2025-03-11T18:32:55Z

Small recap:

1. I replaced the usage of `options.updates`, since those already present are also already consumed at `Platform` level
    https://github.com/qiboteam/qibolab/blob/0fcc6c1e4d4b67ba4977aa4ea6e945156be71aa8/src/qibolab/_core/platform/platform.py#L281
    so, I simply used a standalone `updates` dictionary
2. the core of the proposal was to avoid propagating dictionaries from right after `mesolve` up to the entire collection of all swept values; I replaced them with plain n-dimensional arrays, in which one dimension is dedicated to the measurements, whose order is intended to be the one established by `self._measurements`, and consistent between the samples' extraction, in which I use the values, and the final dictionary reconstruction, in which I establish the output keys from `self._measurements` keys
3. I introduced a plain test, just to ease the purpose of testing - then I realized I needed at least a single meaningful sweeper to check all the reshapes, and I used the following
	```py
	import os
	from pathlib import Path
	
	from qibocal.cli.report import report
	from qibocal.auto.execute import Executor
	
	path = ...
	
	os.environ["QIBOLAB_PLATFORMS"] = str(
	    Path(__file__).parent / "qibolab" / "tests" / "instruments"
	)
	
	with Executor.open("myexec", platform="emulator", path=path, force=True) as e:
	    rs = e.rabi_amplitude(min_amp=0.0, max_amp=1, step_amp=0.01, nshots=1e4)
	
	
	report(path)
	```
    however, it would be nice (and fairly feasible) to extract the sweeper definition from the Qibocal routines, and introduce as a test in Qibolab (more complex tests are not that relevant, since they will boil down to emulator's usage - and we'll do a lot of that, but with Qibocal itself)

There is still material to be polished, especially the `_play` function, for which I should find better names (starting from the function itself, and continuing with all the `res` variables...), and describe a little better what the various steps are doing.

In any case, it is unlikely that this will considerably speed up the execution, since the most expensive part of each loop is still the `mesolve()` invocation. And I fear it is difficult to impossible to vectorize it over sweeper values...
But it should at least streamline a bit the execution, keeping all the merge/reshape complexity at the higher-most level, in a more modular fashion.


Diff:
diff --git a/flake.nix b/flake.nix
index 248ef70501..43c40ed1ee 100644
--- a/flake.nix
+++ b/flake.nix
@@ -54,7 +54,6 @@
               languages.python = {
                 enable = true;
                 libraries = with pkgs; [zlib];
-                version = "3.11";
                 poetry = {
                   enable = true;
                   install = {
@@ -63,7 +62,7 @@
                     extras = [
                       (lib.strings.concatStrings
                         (lib.strings.intersperse " -E "
-                          ["qblox" "qm" "zh" "rfsoc" "los"]))
+                          ["qblox" "qm" "zh" "rfsoc" "los" "emulator"]))
                     ];
                   };
                 };
diff --git a/src/qibolab/_core/instruments/emulator/emulator.py b/src/qibolab/_core/instruments/emulator/emulator.py
index b772bfb928..e2bb2689ac 100644
--- a/src/qibolab/_core/instruments/emulator/emulator.py
+++ b/src/qibolab/_core/instruments/emulator/emulator.py
@@ -2,10 +2,11 @@
 
 from collections import defaultdict
 from functools import reduce
-from itertools import chain
 from operator import or_
+from typing import Any, Optional, cast
 
 import numpy as np
+from numpy.typing import NDArray
 from qutip import mesolve
 
 from qibolab import Readout
@@ -15,13 +16,15 @@
     AveragingMode,
     ExecutionParameters,
 )
+from qibolab._core.identifier import Result
 from qibolab._core.instruments.abstract import Controller
+from qibolab._core.pulses.pulse import PulseId
 from qibolab._core.sequence import PulseSequence
+from qibolab._core.sweeper import ParallelSweepers
 
-from ...serialize import replace
-from .hamiltonians import waveform
+from .hamiltonians import HamiltonianConfig, waveform
 from .operators import INITIAL_STATE, SIGMAZ
-from .utils import merge_results
+from .utils import shots
 
 
 class EmulatorController(Controller):
@@ -46,99 +49,123 @@ def initial_state(self):
         # initial state: qubit in ground state
         return INITIAL_STATE
 
-    def _play_sequence(self, sequence, configs, options):
-        """Play single sequence on emulator."""
-
-        hamiltonian = configs["hamiltonian"].hamiltonian
-        hamiltonian += self._pulse_sequence_to_hamiltonian(sequence, configs, options)
-        measurement, tlist = self._measurement(sequence, configs, options)
+    def _play_sequence(
+        self, sequence: PulseSequence, configs: dict[str, Config], updates: dict
+    ) -> NDArray:
+        """Play single sequence on emulator.
+
+        The array returned by this function has a single dimension, over
+        the various measurements included in the sequence.
+        """
+        config = cast(HamiltonianConfig, configs["hamiltonian"])
+        hamiltonian = config.hamiltonian
+        hamiltonian += self._pulse_sequence_to_hamiltonian(sequence, configs, updates)
+        measurements, tlist = self._measurement(sequence, configs, updates)
         results = mesolve(
-            hamiltonian,
-            self.initial_state,
-            tlist,
-            configs["hamiltonian"].decoherence,
-            e_ops=[SIGMAZ],
+            hamiltonian, self.initial_state, tlist, config.decoherence, e_ops=[SIGMAZ]
         )
-        averaged_results = {
-            ro_pulse_id: (1 - results.expect[0][sample - 1]) / 2
-            for ro_pulse_id, sample in measurement.items()
-        }
-        if options.averaging_mode == AveragingMode.SINGLESHOT:
-            results = {
-                ro_pulse_id: np.random.choice(
-                    [0, 1],
-                    size=options.nshots,
-                    p=[1 - prob, prob],
-                )
-                for ro_pulse_id, prob in averaged_results.items()
-            }
-            return results
-
-        # dropping probability of 0 to keep compatibility with qibolab
-        return {pulse: prob[1] for pulse, prob in averaged_results.items()}
-
-    def _sweep(self, sequence, configs, options, sweepers):
-        """Sweep over sequence."""
-        results = {}
+        samples = np.array(list(measurements.values())) - 1
+        return (1 - results.expect[0][samples]) / 2
+
+    def _sweep(
+        self,
+        sequence: PulseSequence,
+        configs: dict[str, Config],
+        sweepers: list[ParallelSweepers],
+        updates: Optional[dict] = None,
+    ) -> NDArray:
+        """Sweep over sequence.
+
+        This function invokes itself recursively, adding an array
+        dimension at each call as the outermost one. The extra dimension
+        corresponds to the values in the first nested sweep (with the
+        lowest index, interpreted as the outermost as well).
+        """
+        # use a default dictionary, merging existing values
+        updates = defaultdict(dict) | ({} if updates is None else updates)
+
         if len(sweepers) == 0:
-            return self._play_sequence(sequence, configs, options)
-        assert len(sweepers[0]) == 1, "Parallel sweepers not supported."
-        sweeper = sweepers[0][0]
-        updates = dict(chain.from_iterable(d.items() for d in options.updates))
-        for value in sweeper.values:
-            if sweeper.pulses is not None:
-                for pulse in sweeper.pulses:
-                    try:
+            return self._play_sequence(sequence, configs, updates)
+
+        parsweep = sweepers[0]
+        # collect slices of results, corresponding to the current iteration
+        results = []
+        # execute once for each parallel value
+        for values in zip(*(s.values for s in parsweep)):
+            # update all parallel sweepers with the respective values
+            for sweeper, value in zip(parsweep, values):
+                if sweeper.pulses is not None:
+                    for pulse in sweeper.pulses:
                         updates[pulse.id].update({sweeper.parameter.name: value})
-                    except KeyError:
-                        updates[pulse.id] = {sweeper.parameter.name: value}
-            if sweeper.channels is not None:
-                for channel in sweeper.channels:
-                    try:
+                if sweeper.channels is not None:
+                    for channel in sweeper.channels:
                         updates[channel].update({sweeper.parameter.name: value})
-                    except KeyError:
-                        updates[channel] = {sweeper.parameter.name: value}
-            options = replace(options, updates=[{k: v} for k, v in updates.items()])
-            if len(sweepers) > 1:
-                temp = self._sweep(sequence, configs, options, sweepers[1:])
-            else:
-                temp = self._play_sequence(sequence, configs, options)
-
-            results = merge_results(
-                results,
-                temp,
-            )
-
-        # reshaping results
-        for key, value in results.items():
-            results[key] = results[key].reshape(options.results_shape(sweepers))
-        return results
+
+            # append new slice for the current parallel value
+            results.append(self._sweep(sequence, configs, sweepers[1:], updates))
+
+        # stack all slices in a single array, along the current outermost dimension
+        return np.stack(results)
+
+    def _single_sequence(
+        self,
+        sequence: PulseSequence,
+        configs: dict[str, Config],
+        options: ExecutionParameters,
+        sweepers: list[ParallelSweepers],
+    ) -> dict[int, Result]:
+        """Collect results for a single pulse sequence.
+
+        The dictionary returned is already compliant with the expected
+        result for the execution of this single sequence, thus suitable
+        to be returned as is.
+        """
+        # probabilities for the |1> state, for each swept value
+        probabilities = self._sweep(sequence, configs, sweepers)
+
+        assert options.nshots is not None
+        # extract results from probabilities, according to the requested averaging mode
+        res = (
+            shots(probabilities, options.nshots)
+            if options.averaging_mode == AveragingMode.SINGLESHOT
+            else probabilities
+        )
+        # move measurements dimension to the front, getting ready for extraction
+        measurements = np.moveaxis(res, -1, 0)
+        # match measurements with their IDs, in order to already comply with the general
+        # format established by the `Controller` interface
+        measurement_ids = self._measurement(sequence, configs, {})[0].keys()
+        return dict(zip(measurement_ids, list(measurements)))
 
     def play(
         self,
         configs: dict[str, Config],
         sequences: list[PulseSequence],
         options: ExecutionParameters,
-        sweepers: list = None,
-    ):
+        sweepers: list[ParallelSweepers],
+    ) -> dict[int, Result]:
         assert options.acquisition_type == AcquisitionType.DISCRIMINATION, (
             "Emulator only supports DISCRIMINATION acquisition type."
         )
+        # just merge the results of multiple executions in a single dictionary
         return reduce(
             or_,
             (
-                self._sweep(sequence, configs, options, sweepers)
+                self._single_sequence(sequence, configs, options, sweepers)
                 for sequence in sequences
             ),
-            {},
         )
 
-    def _measurement(self, sequence, configs, options):
+    def _measurement(
+        self,
+        sequence: PulseSequence,
+        configs: dict[str, Config],
+        updates: dict,
+    ) -> tuple[dict[PulseId, Any], NDArray]:
         """Given sequence creates a dictionary of readout pulses and their
         sample index."""
         duration = 0
         pulses = {}
-        updates = dict(chain.from_iterable(d.items() for d in options.updates))
         for channel, pulse in sequence:
             if isinstance(configs[channel], AcquisitionConfig):
                 if isinstance(pulse, Readout):
@@ -156,11 +183,10 @@ def _measurement(self, sequence, configs, options):
         return pulses, tlist
 
     def _pulse_sequence_to_hamiltonian(
-        self, sequence: PulseSequence, configs, options
+        self, sequence: PulseSequence, configs: dict[str, Config], updates: dict
     ) -> dict[str, list]:
         """Construct Hamiltonian dependent term for qutip simulation."""
 
-        updates = dict(chain.from_iterable(d.items() for d in options.updates))
         hamiltonians = defaultdict(list)
         h_t = []
         for channel, pulse in sequence:
diff --git a/src/qibolab/_core/instruments/emulator/utils.py b/src/qibolab/_core/instruments/emulator/utils.py
index 031964e273..ca354f41c4 100644
--- a/src/qibolab/_core/instruments/emulator/utils.py
+++ b/src/qibolab/_core/instruments/emulator/utils.py
@@ -1,4 +1,5 @@
 import numpy as np
+from numpy.typing import NDArray
 
 GHZ_TO_HZ = 1e9
 """Converting GHz to Hz."""
@@ -6,10 +7,40 @@
 """Converting Hz to GHz."""
 
 
-def merge_results(a: dict, b: dict) -> dict:
-    """Merge dictionary together using np.column_stack."""
-    merged = a.copy()
-    for key, value in b.items():
-        x = (a[key],) if key in a else ()
-        merged[key] = np.column_stack(x + (value,))
-    return merged
+def ndchoice(probabilities: NDArray, samples: int) -> NDArray:
+    """Sample elements with n-dimensional probabilities.
+
+    This is the n-dimensional version of :func:`np.random.choice`, which instead of
+    vectorizing over the picked elements, it assumes them to be just a plain integer
+    range (which in turn could be used to index a suitable array, if relevant), while it
+    allows the probabilities to be higher dimensional.
+
+    The ``probabilities`` argument specifies the set of probabilities, which are
+    intended to be normalized arrays over the innermost dimension. Such that the whole
+    array describe a set of ``probabilities.shape[:-1]`` discrete distributions.
+
+    `samples` is instead the number of samples to extract from each distribution.
+
+    .. seealso::
+
+        Generalized from https://stackoverflow.com/a/47722393, which presents the
+        two-dimensional version.
+    """
+    return (
+        probabilities.cumsum(-1).reshape(*probabilities.shape, -1)
+        > np.random.rand(*probabilities.shape[:-1], 1, samples)
+    ).argmax(-2)
+
+
+def shots(probabilities: NDArray, nshots: int) -> NDArray:
+    """Extract shots from state |1> probabilities.
+
+    This function just wraps :func:`ndchoice`, taking care of creating the n-D array of
+    binomial distributions, and extracting shots as the outermost dimension.
+    """
+    # discrete distributions of [|0>, |1>] states, over the innermost dimension
+    dists = np.stack([1 - probabilities, probabilities], axis=-1)
+    # shots, in the innermost dimension, replacing the distribution dimension
+    shots = ndchoice(dists, nshots)
+    # move shots from innermost to outermost dimension
+    return np.moveaxis(shots, -1, 0)
diff --git a/tests/instruments/emulator/__init__.py b/tests/instruments/emulator/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/instruments/emulator/conftest.py b/tests/instruments/emulator/conftest.py
new file mode 100644
index 0000000000..56663cd688
--- /dev/null
+++ b/tests/instruments/emulator/conftest.py
@@ -0,0 +1,13 @@
+from pathlib import Path
+
+import pytest
+
+import qibolab
+
+HERE = Path(__file__).parent
+
+
+@pytest.fixture
+def platform(monkeypatch) -> qibolab.Platform:
+    monkeypatch.setenv("QIBOLAB_PLATFORMS", HERE.parent)
+    return qibolab.create_platform(HERE.name)
diff --git a/tests/instruments/emulator/parameters.json b/tests/instruments/emulator/parameters.json
new file mode 100644
index 0000000000..fc47a0f70c
--- /dev/null
+++ b/tests/instruments/emulator/parameters.json
@@ -0,0 +1,115 @@
+{
+  "settings": {
+    "nshots": 1024,
+    "relaxation_time": 0
+  },
+  "configs": {
+    "emulator/bounds": {
+      "kind": "bounds",
+      "waveforms": 1000000,
+      "readout": 50,
+      "instructions": 200
+    },
+    "hamiltonian": {
+      "single_qubit": {
+        "0": {
+          "frequency": 5000000000,
+          "t1": 1000,
+          "t2": 1500
+        }
+      },
+      "kind": "hamiltonian"
+    },
+    "0/drive": {
+      "kind": "iq",
+      "frequency": 5000000000
+    },
+    "0/probe": {
+      "kind": "iq",
+      "frequency": 5200000000.0
+    },
+    "0/acquisition": {
+      "kind": "acquisition",
+      "delay": 0.0,
+      "smearing": 0.0,
+      "threshold": 0.0,
+      "iq_angle": 0.0,
+      "kernel": "k05VTVBZAQB2AHsnZGVzY3InOiAnPGY4JywgJ2ZvcnRyYW5fb3JkZXInOiBGYWxzZSwgJ3NoYXBlJzogKDEwLCksIH0gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAp5sDfS7uHlP2DIMKNvnKc/gCqN8KV/pT94FQCYYJC3PzSbwfi/894/APwg6C61rj8MSN3blizAP2ha9unQYsM/+BFjHTxcwT+gXaJazvbpPw=="
+    }
+  },
+  "native_gates": {
+    "single_qubit": {
+      "0": {
+        "RX": [
+          [
+            "0/drive",
+            {
+              "duration": 100,
+              "amplitude": 0.437,
+              "envelope": {
+                "kind": "gaussian",
+                "rel_sigma": 0.2
+              },
+              "relative_phase": 0.0,
+              "kind": "pulse"
+            }
+          ]
+        ],
+        "RX90": [
+          [
+            "0/drive",
+            {
+              "duration": 100,
+              "amplitude": 0.178,
+              "envelope": {
+                "kind": "gaussian",
+                "rel_sigma": 0.2
+              },
+              "relative_phase": 0.0,
+              "kind": "pulse"
+            }
+          ]
+        ],
+        "RX12": [
+          [
+            "0/drive12",
+            {
+              "duration": 40.0,
+              "amplitude": 0.005,
+              "envelope": {
+                "kind": "gaussian",
+                "rel_sigma": 0.2
+              },
+              "relative_phase": 0.0,
+              "kind": "pulse"
+            }
+          ]
+        ],
+        "MZ": [
+          [
+            "0/acquisition",
+            {
+              "kind": "readout",
+              "acquisition": {
+                "kind": "acquisition",
+                "duration": 2000.0
+              },
+              "probe": {
+                "duration": 2000.0,
+                "amplitude": 0.1,
+                "envelope": {
+                  "kind": "gaussian_square",
+                  "rel_sigma": 0.2,
+                  "width": 0.75
+                },
+                "relative_phase": 0.0,
+                "kind": "pulse"
+              }
+            }
+          ]
+        ],
+        "CP": null
+      }
+    }
+  }
+}
diff --git a/tests/instruments/emulator/platform.py b/tests/instruments/emulator/platform.py
new file mode 100644
index 0000000000..4a6ef83b68
--- /dev/null
+++ b/tests/instruments/emulator/platform.py
@@ -0,0 +1,35 @@
+import pathlib
+
+from qibolab import ConfigKinds
+from qibolab._core.components import IqChannel
+from qibolab._core.instruments.emulator.emulator import EmulatorController
+from qibolab._core.instruments.emulator.hamiltonians import HamiltonianConfig
+from qibolab._core.platform import Platform
+from qibolab._core.qubits import Qubit
+
+FOLDER = pathlib.Path(__file__).parent
+
+ConfigKinds.extend([HamiltonianConfig])
+
+
+def create() -> Platform:
+    """Create a dummy platform using the dummy instrument."""
+    qubits = {}
+    channels = {}
+
+    for q in range(1):
+        qubits[q] = qubit = Qubit.default(q)
+        channels |= {
+            qubit.drive: IqChannel(mixer=None, lo=None),
+        }
+
+    # register the instruments
+    instruments = {
+        "dummy": EmulatorController(address="0.0.0.0", channels=channels),
+    }
+
+    return Platform.load(
+        path=FOLDER,
+        instruments=instruments,
+        qubits=qubits,
+    )
diff --git a/tests/instruments/emulator/test_sequence.py b/tests/instruments/emulator/test_sequence.py
new file mode 100644
index 0000000000..b4779da665
--- /dev/null
+++ b/tests/instruments/emulator/test_sequence.py
@@ -0,0 +1,12 @@
+import pytest
+
+from qibolab import Platform
+
+
+def test_sequence(platform: Platform):
+    q0 = platform.natives.single_qubit[0]
+    mz = q0.MZ()
+    sequences = [(mz, 0), (q0.RX() | mz, 1), (q0.RX() | q0.RX() | mz, 0)]
+    for ps, mean in sequences:
+        res = platform.execute([ps], nshots=1e4)[mz[0][1].id]
+        assert pytest.approx(res.mean(), abs=1e-1) == mean


Reviews:
user: login: andrea-pasquale
body: Thanks @alecandido.
Before I check the code I tried to run the example that you proposed in the comment above. However the code is failing with the following error:
<details closed>
<summary>ERROR</summary>

```bash
[Qibo 0.2.16|INFO|2025-03-10 09:16:48]: Loading platform dummy
[Qibo 0.2.16|INFO|2025-03-10 09:16:48]: Loading platform dummy
[Qibo 0.2.16|INFO|2025-03-10 09:16:48]: Loading platform emulator
[Qibo 0.2.16|INFO|2025-03-10 09:16:48]: Loading platform emulator
[Qibo 0.2.16|INFO|2025-03-10 09:16:48]: Loading platform emulator
[Qibo 0.2.16|INFO|2025-03-10 09:16:48]: Loading platform emulator
[Qibo 0.2.16|INFO|2025-03-10 09:16:48]: Loading platform emulator
[Qibocal 0.1.3|WARNING|2025-03-10 09:16:48]: Deleting previous directory test_emulator.
[Qibocal 0.1.3|INFO|2025-03-10 09:16:48]: Creating directory test_emulator.
[Qibocal 0.1.3|INFO|2025-03-10 09:16:48]: Executing mode AUTOCALIBRATION on rabi_amplitude.
[Qibo 0.2.16|INFO|2025-03-10 09:16:48]: Minimal execution time: 2.1
Traceback (most recent call last):
  File "/Users/andreapasquale/qibolab/test_emulator.py", line 14, in <module>
    rs = e.rabi_amplitude(min_amp=0, max_amp=1, step_amp=0.01, nshots=1e4)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/execute.py", line 223, in wrapper
    return self.run_protocol(protocol, parameters=action, mode=mode)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/execute.py", line 128, in run_protocol
    completed = task.run(platform=self.platform, targets=self.targets, mode=mode)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/task.py", line 159, in run
    completed.data, completed.data_time = operation.acquisition(
                                          ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/operation.py", line 40, in wrapper
    out = func(*args, **kwds)
          ^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/protocols/rabi/amplitude.py", line 72, in _acquisition
    results = platform.execute(
              ^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/platform/platform.py", line 294, in execute
    results |= self._execute(b, options, configs, sweepers)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/platform/platform.py", line 228, in _execute
    new_result = instrument.play(configs, sequences, options, sweepers)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 125, in play
    return reduce(
           ^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 128, in <genexpr>
    self._play(sequence, configs, options, sweepers)
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 98, in _play
    res = self._sweep(sequence, configs, sweepers)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 87, in _sweep
    results.append(self._sweep(sequence, configs, sweepers[1:], updates))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 76, in _sweep
    return self._play_sequence(sequence, configs, updates)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 60, in _play_sequence
    results = mesolve(
              ^^^^^^^^
  File "/Users/andreapasquale/default/lib/python3.12/site-packages/qutip/solver/mesolve.py", line 151, in mesolve
    H = QobjEvo(H, args=args, tlist=tlist)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "qutip/core/cy/qobjevo.pyx", line 223, in qutip.core.cy.qobjevo.QobjEvo.__init__
  File "qutip/core/cy/qobjevo.pyx", line 272, in qutip.core.cy.qobjevo.QobjEvo._read_element
  File "/Users/andreapasquale/default/lib/python3.12/site-packages/qutip/core/coefficient.py", line 195, in coefficient
    raise TypeError("The coefficient function must return a number")
TypeError: The coefficient function must return a number
```
</details>

This particular example also fails on the `emulator` branch (using https://github.com/qiboteam/qibocal/pull/1103 with the platform `emulator1q`) but the error is `ValueError: probabilities are not non-negative` which leads me to think that the probabilities are not defined properly with amplitude set to 0. By changing the parameters the minimum amplitude to `0.1` I am getting some results.

![Screenshot 2025-03-10 at 09 28 57](https://github.com/user-attachments/assets/d428a95a-3803-4d14-837c-9b62f441856e)

Here is the script which I have used: 

<details closed>
<summary>SCRIPT</summary>

```py
import os
from pathlib import Path

from qibocal.cli.report import report
from qibocal.auto.execute import Executor

path = Path("test_emulator")

# os.environ["QIBOLAB_PLATFORMS"] = str(
#     Path(__file__).parent.parent / "qibolab" / "tests" / "instruments"
# )

with Executor.open("myexec", platform="emulator1q", path=path, force=True) as e:
    rs = e.rabi_amplitude(min_amp=0.1, max_amp=0.4, step_amp=0.01, nshots=1e4)


report(path)
```

</details>
Using the same "fix" on this branch I am getting this error instead 

<details closed>

<summary>ERROR</summary>

```bash
[Qibo 0.2.16|INFO|2025-03-10 09:27:35]: Loading platform dummy
[Qibo 0.2.16|INFO|2025-03-10 09:27:35]: Loading platform dummy
[Qibo 0.2.16|INFO|2025-03-10 09:27:35]: Loading platform emulator1q
[Qibo 0.2.16|INFO|2025-03-10 09:27:35]: Loading platform emulator1q
[Qibo 0.2.16|INFO|2025-03-10 09:27:35]: Loading platform emulator1q
[Qibo 0.2.16|INFO|2025-03-10 09:27:35]: Loading platform emulator1q
[Qibo 0.2.16|INFO|2025-03-10 09:27:35]: Loading platform emulator1q
[Qibocal 0.1.3|WARNING|2025-03-10 09:27:35]: Deleting previous directory test_emulator.
[Qibocal 0.1.3|INFO|2025-03-10 09:27:35]: Creating directory test_emulator.
[Qibocal 0.1.3|INFO|2025-03-10 09:27:35]: Executing mode AUTOCALIBRATION on rabi_amplitude.
[Qibo 0.2.16|INFO|2025-03-10 09:27:35]: Minimal execution time: 0.651
[Qibocal 0.1.3|INFO|2025-03-10 09:27:40]: Finished acquisition in 4.78 seconds
[Qibocal 0.1.3|INFO|2025-03-10 09:27:40]: Finished fit in less than 1 second.
(default) andreapasquale ~/qibolab [emulator] $ open test_emulator/index.html 
(default) andreapasquale ~/qibolab [emulator] $ git ch vectorize-emulator
Switched to branch 'vectorize-emulator'
Your branch is up to date with 'origin/vectorize-emulator'.
(default) andreapasquale ~/qibolab [vectorize-emulator] $ python test_emulator.py      
[Qibo 0.2.16|INFO|2025-03-10 09:35:15]: Loading platform dummy
[Qibo 0.2.16|INFO|2025-03-10 09:35:15]: Loading platform dummy
Traceback (most recent call last):
  File "/Users/andreapasquale/qibolab/test_emulator.py", line 13, in <module>
    with Executor.open("myexec", platform="emulator", path=path, force=True) as e:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py", line 137, in __enter__
    return next(self.gen)
           ^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/execute.py", line 320, in open
    ex = cls.create(name, platform)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/execute.py", line 106, in create
    else create_calibration_platform(
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/calibration/platform.py", line 37, in create_calibration_platform
    platform = create_platform(name)
               ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/platform/load.py", line 81, in create_platform
    hardware = _load(path)
               ^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/platform/load.py", line 48, in _load
    return module.create()
           ^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab_platforms_qrc/emulator/platform.py", line 32, in create
    return Platform.load(
           ^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/platform/platform.py", line 308, in load
    parameters = Parameters.model_validate_json((path / PARAMETERS).read_text())
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/default/lib/python3.12/site-packages/pydantic/main.py", line 656, in model_validate_json
    return cls.__pydantic_validator__.validate_json(json_data, strict=strict, context=context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 1 validation error for Parameters
configs.hamiltonian.single_qubit.0.anharmonicity
  Extra inputs are not permitted [type=extra_forbidden, input_value=-200000000, input_type=int]
    For further information visit https://errors.pydantic.dev/2.10/v/extra_forbidden
(default) andreapasquale ~/qibolab [vectorize-emulator] $ python test_emulator.py
[Qibo 0.2.16|INFO|2025-03-10 09:35:26]: Loading platform dummy
[Qibo 0.2.16|INFO|2025-03-10 09:35:26]: Loading platform dummy
[Qibo 0.2.16|INFO|2025-03-10 09:35:27]: Loading platform emulator
[Qibo 0.2.16|INFO|2025-03-10 09:35:27]: Loading platform emulator
[Qibo 0.2.16|INFO|2025-03-10 09:35:27]: Loading platform emulator
[Qibo 0.2.16|INFO|2025-03-10 09:35:27]: Loading platform emulator
[Qibo 0.2.16|INFO|2025-03-10 09:35:27]: Loading platform emulator
[Qibocal 0.1.3|WARNING|2025-03-10 09:35:27]: Deleting previous directory test_emulator.
[Qibocal 0.1.3|INFO|2025-03-10 09:35:27]: Creating directory test_emulator.
[Qibocal 0.1.3|INFO|2025-03-10 09:35:27]: Executing mode AUTOCALIBRATION on rabi_amplitude.
[Qibo 0.2.16|INFO|2025-03-10 09:35:27]: Minimal execution time: 0.651
Traceback (most recent call last):
  File "/Users/andreapasquale/qibolab/test_emulator.py", line 14, in <module>
    rs = e.rabi_amplitude(min_amp=0.1, max_amp=0.4, step_amp=0.01, nshots=1e4)
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/execute.py", line 223, in wrapper
    return self.run_protocol(protocol, parameters=action, mode=mode)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/execute.py", line 128, in run_protocol
    completed = task.run(platform=self.platform, targets=self.targets, mode=mode)
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/task.py", line 159, in run
    completed.data, completed.data_time = operation.acquisition(
                                          ^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/auto/operation.py", line 40, in wrapper
    out = func(*args, **kwds)
          ^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibocal/src/qibocal/protocols/rabi/amplitude.py", line 72, in _acquisition
    results = platform.execute(
              ^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/platform/platform.py", line 294, in execute
    results |= self._execute(b, options, configs, sweepers)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/platform/platform.py", line 228, in _execute
    new_result = instrument.play(configs, sequences, options, sweepers)
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 125, in play
    return reduce(
           ^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 128, in <genexpr>
    self._play(sequence, configs, options, sweepers)
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 98, in _play
    res = self._sweep(sequence, configs, sweepers)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 87, in _sweep
    results.append(self._sweep(sequence, configs, sweepers[1:], updates))
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 76, in _sweep
    return self._play_sequence(sequence, configs, updates)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 60, in _play_sequence
    results = mesolve(
              ^^^^^^^^
  File "/Users/andreapasquale/default/lib/python3.12/site-packages/qutip/solver/mesolve.py", line 151, in mesolve
    H = QobjEvo(H, args=args, tlist=tlist)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "qutip/core/cy/qobjevo.pyx", line 223, in qutip.core.cy.qobjevo.QobjEvo.__init__
  File "qutip/core/cy/qobjevo.pyx", line 272, in qutip.core.cy.qobjevo.QobjEvo._read_element
  File "/Users/andreapasquale/default/lib/python3.12/site-packages/qutip/core/coefficient.py", line 194, in coefficient
    if not isinstance(op(0), numbers.Number):
                      ^^^^^
  File "qutip/core/cy/coefficient.pyx", line 220, in qutip.core.cy.coefficient.FunctionCoefficient.__call__
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/emulator.py", line 181, in time
    return pulse(t, index)
           ^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/hamiltonians.py", line 69, in __call__
    i, q = self.envelopes
           ^^^^^^^^^^^^^^
  File "/Library/Frameworks/Python.framework/Versions/3.12/lib/python3.12/functools.py", line 993, in __get__
    val = self.func(instance)
          ^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/instruments/emulator/hamiltonians.py", line 56, in envelopes
    return self.pulse.envelopes(self.sampling_rate)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/pulses/pulse.py", line 69, in envelopes
    return np.array([self.i(sampling_rate), self.q(sampling_rate)])
                     ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/andreapasquale/qibolab/src/qibolab/_core/pulses/pulse.py", line 60, in i
    return self.amplitude * self.envelope.i(samples)
           ~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~~~~~
ValueError: operands could not be broadcast together with shapes (31,) (100,) 
```
</details>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-10T08:37:08Z
user: login: andrea-pasquale
body: I can confirm that now the code is working also from my side.
It seems to be also relatively faster, although readability-wise I find a bit more difficult to follow.
Just one comment related to this
> 1. I replaced the usage of `options.updates`, since those already present are also already consumed at `Platform` level
>        https://github.com/qiboteam/qibolab/blob/0fcc6c1e4d4b67ba4977aa4ea6e945156be71aa8/src/qibolab/_core/platform/platform.py#L281

Sooner or later, when we introduce different `ExecutionParameters` we will need to pass the `options` as well, so maybe we could consider going back to the previous implementation at some point (just to avoid having an extra argument). Although on the other hand I undestand that I was perhaps misinterpreting the usage of `options.updates`.


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T08:03:56Z
user: login: alecandido
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T08:12:51Z
user: login: andrea-pasquale
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T08:20:53Z
user: login: alecandido
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T17:06:58Z
user: login: alecandido
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T17:11:07Z
user: login: alecandido
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-11T17:12:51Z
user: login: andrea-pasquale
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T18:00:43Z

Review comments:
path: tests/instruments/emulator/calibration.json
user: login: andrea-pasquale
body: Probably for testing with just qibolab we don't need this file.
created_at: 2025-03-10T17:44:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,99 +49,100 @@ def initial_state(self):
         # initial state: qubit in ground state
         return INITIAL_STATE
 
-    def _play_sequence(self, sequence, configs, options):
+    def _play_sequence(
+        self, sequence: PulseSequence, configs: dict[str, Config], updates: dict
+    ) -> NDArray:
         """Play single sequence on emulator."""
-
-        hamiltonian = configs["hamiltonian"].hamiltonian
-        hamiltonian += self._pulse_sequence_to_hamiltonian(sequence, configs, options)
-        measurement, tlist = self._measurement(sequence, configs, options)
+        config = cast(HamiltonianConfig, configs["hamiltonian"])
+        hamiltonian = config.hamiltonian
+        hamiltonian += self._pulse_sequence_to_hamiltonian(sequence, configs, updates)
+        measurements, tlist = self._measurement(sequence, configs, updates)
         results = mesolve(
-            hamiltonian,
-            self.initial_state,
-            tlist,
-            configs["hamiltonian"].decoherence,
-            e_ops=[SIGMAZ],
+            hamiltonian, self.initial_state, tlist, config.decoherence, e_ops=[SIGMAZ]
         )
-        averaged_results = {
-            ro_pulse_id: (1 - results.expect[0][sample - 1]) / 2
-            for ro_pulse_id, sample in measurement.items()
-        }
-        if options.averaging_mode == AveragingMode.SINGLESHOT:
-            results = {
-                ro_pulse_id: np.random.choice(
-                    [0, 1],
-                    size=options.nshots,
-                    p=[1 - prob, prob],
-                )
-                for ro_pulse_id, prob in averaged_results.items()
-            }
-            return results
-
-        # dropping probability of 0 to keep compatibility with qibolab
-        return {pulse: prob[1] for pulse, prob in averaged_results.items()}
-
-    def _sweep(self, sequence, configs, options, sweepers):
+        samples = np.array(list(measurements.values())) - 1
+        return (1 - results.expect[0][samples]) / 2
+
+    def _sweep(
+        self,
+        sequence: PulseSequence,
+        configs: dict[str, Config],
+        sweepers: list[ParallelSweepers],
+        updates: Optional[dict] = None,
+    ) -> NDArray:
         """Sweep over sequence."""
-        results = {}
+        updates = defaultdict(dict) | ({} if updates is None else updates)
         if len(sweepers) == 0:
-            return self._play_sequence(sequence, configs, options)
-        assert len(sweepers[0]) == 1, "Parallel sweepers not supported."
-        sweeper = sweepers[0][0]
-        updates = dict(chain.from_iterable(d.items() for d in options.updates))
-        for value in sweeper.values:
-            if sweeper.pulses is not None:
-                for pulse in sweeper.pulses:
-                    try:
+            return self._play_sequence(sequence, configs, updates)
+
+        parsweep = sweepers[0]
+        results = []
+        # execute once for each parallel value
+        for values in zip(*(s.values for s in parsweep)):
+            # update all parallel sweepers with the respective values
+            for sweeper, value in zip(parsweep, values):
+                if sweeper.pulses is not None:
+                    for pulse in sweeper.pulses:
path: src/qibolab/_core/instruments/emulator/emulator.py
user: login: andrea-pasquale
body: The updates below for pulses shouldn't it be similar to the one that you refactored below for pulses?
created_at: 2025-03-10T18:05:54Z
author_association: CONTRIBUTOR
path: tests/instruments/emulator/calibration.json
user: login: alecandido
body: I needed because I used this platform offline to run some Qibocal routines as well.

On the one hand, I perfectly agree with you, it'd be more minimal without.
On the other: is it optional for Qibocal? I did not check. If it is not, it may be practical to keep it, just to quickly allow running routines as well, though it will never be used other than for offline debugging.

As soon as we implement suitable tests with sweepers, we certainly do not need it any longer (since we can avoid relying on the plot to check that everything works as expected).
created_at: 2025-03-11T08:12:51Z
author_association: MEMBER
path: tests/instruments/emulator/calibration.json
user: login: andrea-pasquale
body: For qibocal it is mandatory (especially because some of the routines are actively using parameters from this file).
For debugging I've added an emulator directly in https://github.com/qiboteam/qibocal/pull/1103.
More generally I intend to populate [`platforms`](https://github.com/qiboteam/qibocal/tree/emulator/platforms) with exactly these "debugging platforms" which in this case are just `mock` and `emulator1q`.

created_at: 2025-03-11T08:20:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,99 +49,100 @@ def initial_state(self):
         # initial state: qubit in ground state
         return INITIAL_STATE
 
-    def _play_sequence(self, sequence, configs, options):
+    def _play_sequence(
+        self, sequence: PulseSequence, configs: dict[str, Config], updates: dict
+    ) -> NDArray:
         """Play single sequence on emulator."""
-
-        hamiltonian = configs["hamiltonian"].hamiltonian
-        hamiltonian += self._pulse_sequence_to_hamiltonian(sequence, configs, options)
-        measurement, tlist = self._measurement(sequence, configs, options)
+        config = cast(HamiltonianConfig, configs["hamiltonian"])
+        hamiltonian = config.hamiltonian
+        hamiltonian += self._pulse_sequence_to_hamiltonian(sequence, configs, updates)
+        measurements, tlist = self._measurement(sequence, configs, updates)
         results = mesolve(
-            hamiltonian,
-            self.initial_state,
-            tlist,
-            configs["hamiltonian"].decoherence,
-            e_ops=[SIGMAZ],
+            hamiltonian, self.initial_state, tlist, config.decoherence, e_ops=[SIGMAZ]
         )
-        averaged_results = {
-            ro_pulse_id: (1 - results.expect[0][sample - 1]) / 2
-            for ro_pulse_id, sample in measurement.items()
-        }
-        if options.averaging_mode == AveragingMode.SINGLESHOT:
-            results = {
-                ro_pulse_id: np.random.choice(
-                    [0, 1],
-                    size=options.nshots,
-                    p=[1 - prob, prob],
-                )
-                for ro_pulse_id, prob in averaged_results.items()
-            }
-            return results
-
-        # dropping probability of 0 to keep compatibility with qibolab
-        return {pulse: prob[1] for pulse, prob in averaged_results.items()}
-
-    def _sweep(self, sequence, configs, options, sweepers):
+        samples = np.array(list(measurements.values())) - 1
+        return (1 - results.expect[0][samples]) / 2
+
+    def _sweep(
+        self,
+        sequence: PulseSequence,
+        configs: dict[str, Config],
+        sweepers: list[ParallelSweepers],
+        updates: Optional[dict] = None,
+    ) -> NDArray:
         """Sweep over sequence."""
-        results = {}
+        updates = defaultdict(dict) | ({} if updates is None else updates)
         if len(sweepers) == 0:
-            return self._play_sequence(sequence, configs, options)
-        assert len(sweepers[0]) == 1, "Parallel sweepers not supported."
-        sweeper = sweepers[0][0]
-        updates = dict(chain.from_iterable(d.items() for d in options.updates))
-        for value in sweeper.values:
-            if sweeper.pulses is not None:
-                for pulse in sweeper.pulses:
-                    try:
+            return self._play_sequence(sequence, configs, updates)
+
+        parsweep = sweepers[0]
+        results = []
+        # execute once for each parallel value
+        for values in zip(*(s.values for s in parsweep)):
+            # update all parallel sweepers with the respective values
+            for sweeper, value in zip(parsweep, values):
+                if sweeper.pulses is not None:
+                    for pulse in sweeper.pulses:
path: src/qibolab/_core/instruments/emulator/emulator.py
user: login: alecandido
body: I guess you meant the one for *channels*. In which case, it was indeed the bug observed in the 2D Rabi, which it was fixed in bfad4e5
created_at: 2025-03-11T17:06:58Z
author_association: MEMBER
path: tests/instruments/emulator/calibration.json
user: login: alecandido
body: I'm thinking whether to ask to keep this file temporarily, just for the convenience of having it in a single repo.

However, it is true that I can just point my `QIBOLAB_PLATFORMS` somewhere else, and load the `emulator1q` as well (of which this is just a copy, for the convenience of the CI).

So, I guess we can drop this file, and just avoid relying on the same tools of Pytest to manually run routines (which I avoided here, and I'd never include in this repo, to prevent any kind of loop).
created_at: 2025-03-11T17:11:07Z
author_association: MEMBER
path: tests/instruments/emulator/calibration.json
user: login: alecandido
body: Done in d9e46ad6ac8ef464ebe6a089cf22202701ea6169
created_at: 2025-03-11T17:12:51Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-07T18:30:46Z
body: ## [Codecov](https://app.codecov.io/gh/qiboteam/qibolab/pull/1168?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam) Report
Attention: Patch coverage is `0%` with `42 lines` in your changes missing coverage. Please review.
> Project coverage is 49.58%. Comparing base [(`a849ef0`)](https://app.codecov.io/gh/qiboteam/qibolab/commit/a849ef04f4fecfe3f952514c8ebe44e4ca075194?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam) to head [(`d9e46ad`)](https://app.codecov.io/gh/qiboteam/qibolab/commit/d9e46ad6ac8ef464ebe6a089cf22202701ea6169?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam).
> Report is 14 commits behind head on emulator.

| [Files with missing lines](https://app.codecov.io/gh/qiboteam/qibolab/pull/1168?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam) | Patch % | Lines |
|---|---|---|
| [src/qibolab/\_core/instruments/emulator/emulator.py](https://app.codecov.io/gh/qiboteam/qibolab/pull/1168?src=pr&el=tree&filepath=src%2Fqibolab%2F_core%2Finstruments%2Femulator%2Femulator.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam#diff-c3JjL3FpYm9sYWIvX2NvcmUvaW5zdHJ1bWVudHMvZW11bGF0b3IvZW11bGF0b3IucHk=) | 0.00% | [35 Missing :warning: ](https://app.codecov.io/gh/qiboteam/qibolab/pull/1168?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam) |
| [src/qibolab/\_core/instruments/emulator/utils.py](https://app.codecov.io/gh/qiboteam/qibolab/pull/1168?src=pr&el=tree&filepath=src%2Fqibolab%2F_core%2Finstruments%2Femulator%2Futils.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam#diff-c3JjL3FpYm9sYWIvX2NvcmUvaW5zdHJ1bWVudHMvZW11bGF0b3IvdXRpbHMucHk=) | 0.00% | [7 Missing :warning: ](https://app.codecov.io/gh/qiboteam/qibolab/pull/1168?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam) |

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff              @@
##           emulator    #1168      +/-   ##
============================================
+ Coverage     49.51%   49.58%   +0.07%     
============================================
  Files            76       76              
  Lines          3381     3376       -5     
============================================
  Hits           1674     1674              
+ Misses         1707     1702       -5     
```

| [Flag](https://app.codecov.io/gh/qiboteam/qibolab/pull/1168/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/qiboteam/qibolab/pull/1168/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam) | `49.58% <0.00%> (+0.07%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/qiboteam/qibolab/pull/1168?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=qiboteam).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: alecandido
created_at: 2025-03-07T18:32:45Z
body: Btw, I did not touch the `EmulatorController._measurement()` and `._pulse_sequence_to_hamiltonian()` functions, working mostly on updates and results.

I intend to properly review even those, but not in this PR.
author_association: MEMBER
user: login: alecandido
created_at: 2025-03-10T09:07:46Z
body: Thanks @andrea-pasquale for testing this.

Indeed, in order to run, even on the https://github.com/qiboteam/qibolab/tree/emulator branch, I actually patched the code slightly, in a way which I did not report here.
I.e. I just put an `abs()` around `prob`, since I assumed the negative values were originated by tiny fluctuations around zero values.

In any case, it was just a quick fix to run, so I did not publish. But I obtained results with both branches, and I compared them to be the same.

At this point, I will try to reinstall from scratch, and attempt to reproduce one of the results (either your error, or the observed reports).
author_association: MEMBER
user: login: alecandido
created_at: 2025-03-10T17:40:11Z
body: @andrea-pasquale, apparently, the error was introduced in the last commit I added (before requesting the review, i.e. c7001ae, not the current last, which is the fix), in which I introduced the support for parallel sweepers. But, it seems that I did not test this last part... my bad...

However, now I tried again, and it seems to work, with both your parameters and my original ones.
| Restricted | Full scan |
|--------|--------|
| ![image](https://github.com/user-attachments/assets/817f2039-a623-4569-896a-ee2df1a782c4) | ![image](https://github.com/user-attachments/assets/a66a140e-dd26-4067-9777-ed187c63d991) |
author_association: MEMBER
user: login: alecandido
created_at: 2025-03-11T17:14:25Z
body: @andrea-pasquale: now I consider this to be completed. If you do not have any further suggestion, or anything else to amend, I'd rather merge this PR, and move forward with next enhancements (starting from the simple integration mode, but also flux and so on).
author_association: MEMBER