Repository: microsoft/Qcodes

Issue:
`plot_by_id` doesn't handle identical 2D setpoints
When using `plot_by_id` a non-descriptive error is raised when identical setpoints are provided. Although it is stupid to try to plot something as a function of `[1, 1, 1]` and `[0, 0, 0]`, we should handle that stupidity properly. And a single point in 2D space ought to work.

### Steps to reproduce

```python
import numpy as np
import qcodes as qc
from qcodes.dataset.measurements import Measurement
from qcodes.dataset.plotting import plot_by_id
from qcodes.tests.instrument_mocks import DummyInstrument

dac = DummyInstrument('dac', gates=['ch1', 'ch2'])
dmm = DummyInstrument('dmm', gates=['v1', 'v2'])

meas = Measurement()
meas.register_parameter(dac.ch1)  # register the first independent parameter
meas.register_parameter(dac.ch2)  # register the second independent parameter
meas.register_parameter(dmm.v1, setpoints=(dac.ch1, dac.ch2))  # now register the dependent oone

with meas.run() as datasaver:
    for _ in range(20):
        datasaver.add_result((dac.ch1, 1),
                     (dac.ch2, 2),
                     (dmm.v1, 0))

run_id = datasaver.run_id

ax, cbax = plot_by_id(run_id)
```

### Expected behaviour

`plot_by_id` gives a warning that the data are not visualisable, but no exception is raised (or what? Should this be an exception?). Or maybe fall back to a scatter plot with all points on top of each other?

### Actual behaviour

The following non-descript traceback:

```
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-2-802c27256545> in <module>()
     23 run_id = datasaver.run_id
     24 
---> 25 ax, cbax = plot_by_id(run_id)

~/sourcecodes/QCoDeS/qcodes/dataset/plotting.py in plot_by_id(run_id, axes)
    115             log.debug('Plotting by id, determining plottype')
    116             plottype = datatype_from_setpoints_2d([data[0]['data'],
--> 117                                                    data[1]['data']])
    118 
    119             if plottype in how_to_plot.keys():

~/sourcecodes/QCoDeS/qcodes/dataset/data_export.py in datatype_from_setpoints_2d(setpoints)
    209         return 'grid'
    210 
--> 211     x_check = _all_steps_multiples_of_min_step(xrows)
    212     y_check = _all_steps_multiples_of_min_step(yrows)
    213 

~/sourcecodes/QCoDeS/qcodes/dataset/data_export.py in _all_steps_multiples_of_min_step(rows)
     77 
     78     steps = np.unique((steps))
---> 79     remainders = np.mod(steps[1:]/steps[0], 1)
     80 
     81     # TODO: What are reasonable tolerances for allclose?

IndexError: index 0 is out of bounds for axis 0 with size 0
```

### Side note

Line plots with identical setpoints should probably behave the same way as whatever way we decide 2D plots to behave. Currently a line plot with identical setpoints just makes an empty plot with no warning or error.

### System
**macOS**

**master**

** 9da1883**

@QCoDeS/core 



Discussion:

Resolving patch:
diff --git a/qcodes/dataset/data_export.py b/qcodes/dataset/data_export.py
--- a/qcodes/dataset/data_export.py
+++ b/qcodes/dataset/data_export.py
@@ -164,6 +164,25 @@ def _all_in_group_or_subgroup(rows: np.ndarray) -> bool:
     return aigos
 
 
+def datatype_from_setpoints_1d(setpoints: np.ndarray) -> str:
+    """
+    Figure out what type of visualisation is proper for the
+    provided setpoints.
+
+    Args:
+        setpoints: The x-axis values
+
+    Returns:
+        A string which is 'point' if all the setpoints are identical,
+            else it returns 'line'
+    """
+
+    if np.allclose(setpoints, setpoints[0]):
+        return 'point'
+    else:
+        return 'line'
+
+
 def datatype_from_setpoints_2d(setpoints: List[np.ndarray]) -> str:
     """
     For a 2D plot, figure out what kind of visualisation we can use
@@ -188,6 +207,14 @@ def datatype_from_setpoints_2d(setpoints: List[np.ndarray]) -> str:
     xpoints = flatten_1D_data_for_plot(setpoints[0])
     ypoints = flatten_1D_data_for_plot(setpoints[1])
 
+    # First check whether all setpoints are identical along
+    # any dimension
+    x_all_the_same = np.allclose(xpoints, xpoints[0])
+    y_all_the_same = np.allclose(ypoints, ypoints[0])
+
+    if x_all_the_same or y_all_the_same:
+        return 'point'
+
     # Now check if this is a simple rectangular sweep,
     # possibly interrupted in the middle of one row
 
diff --git a/qcodes/dataset/plotting.py b/qcodes/dataset/plotting.py
--- a/qcodes/dataset/plotting.py
+++ b/qcodes/dataset/plotting.py
@@ -8,16 +8,43 @@
 import qcodes as qc
 
 from .data_export import get_data_by_id, flatten_1D_data_for_plot
-from .data_export import datatype_from_setpoints_2d, reshape_2D_data
+from .data_export import (datatype_from_setpoints_1d,
+                          datatype_from_setpoints_2d, reshape_2D_data)
 
 log = logging.getLogger(__name__)
 DB = qc.config["core"]["db_location"]
 
+AxesTuple = Tuple[matplotlib.axes.Axes, matplotlib.axes.Axes]
+
 
 def plot_by_id(run_id: int,
                axes: Optional[Union[matplotlib.axes.Axes,
                      Sequence[matplotlib.axes.Axes]]]=None) -> Tuple[List[matplotlib.axes.Axes],
                                                         List[Optional[matplotlib.axes.Axes]]]:
+    """
+    Construct all plots for a given run
+
+    Implemented so far:
+       * 1D plots
+       * 2D plots on filled out rectangular grids
+       * 2D scatterplots (fallback)
+
+    The function can optionally be supplied with a matplotlib axes
+    or a list of axes that will be used for plotting. The user should ensure
+    that the number of axes matches the number of datasets to plot. To plot
+    several (1D) dataset in the same axes supply it several times. Colorbar
+    axes are created dynamically and cannot be supplied.
+
+    Args:
+        run_id: ID of the dataset to plot
+        axes: Matplotlib axes to plot on
+
+    Returns:
+        a list of axes and a list of colorbar axes of the same length.
+        The colorbar axes may be None if no colorbar is created (e.g. for
+        1D plots)
+    """
+
     def set_axis_labels(ax, data, cax=None):
         if data[0]['label'] == '':
             lbl = data[0]['name']
@@ -40,7 +67,7 @@ def set_axis_labels(ax, data, cax=None):
             unit = data[1]['unit']
             unit = f'({unit})'
         ax.set_ylabel(f'{lbl} {unit}')
-        if cax is not None and len(data)>2:
+        if cax is not None and len(data) > 2:
             if data[2]['label'] == '':
                 lbl = data[2]['name']
             else:
@@ -52,29 +79,6 @@ def set_axis_labels(ax, data, cax=None):
                 unit = f'({unit})'
             cax.set_label(f'{lbl} {unit}')
 
-
-    """
-    Construct all plots for a given run
-
-    Implemented so far:
-       * 1D plots
-       * 2D plots on filled out rectangular grids
-
-    The function can optionally be supplied with a matplotlib axes
-    or a list of axes that will be used for plotting. The user should ensure
-    that the number of axes matches the number of datasets to plot. To plot
-    several (1D) dataset in the same axes supply it several times. Colorbar
-    axes are created dynamically and cannot be supplied.
-
-    Args:
-        run_id: ID of the dataset to plot
-        axes: Matplotlib axes to plot on
-
-    Returns:
-        a list of axes and a list of colorbar axes of the same length.
-        The colorbar axes may be None if no colorbar is created (e.g. for
-        1D plots)
-    """
     alldata = get_data_by_id(run_id)
     nplots = len(alldata)
     if isinstance(axes, matplotlib.axes.Axes):
@@ -83,7 +87,7 @@ def set_axis_labels(ax, data, cax=None):
     if axes is None:
         axes = []
         for i in range(nplots):
-            fig, ax = plt.subplots(1,1)
+            fig, ax = plt.subplots(1, 1)
             axes.append(ax)
     else:
         if len(axes) != nplots:
@@ -98,8 +102,18 @@ def set_axis_labels(ax, data, cax=None):
 
             # sort for plotting
             order = data[0]['data'].argsort()
+            xpoints = data[0]['data'][order]
+            ypoints = data[1]['data'][order]
+
+            plottype = datatype_from_setpoints_1d(xpoints)
+
+            if plottype == 'line':
+                ax.plot(xpoints, ypoints)
+            elif plottype == 'point':
+                ax.scatter(xpoints, ypoints)
+            else:
+                raise ValueError('Unknown plottype. Something is way wrong.')
 
-            ax.plot(data[0]['data'][order], data[1]['data'][order])
             set_axis_labels(ax, data)
             cbaxes.append(None)
 
@@ -110,48 +124,57 @@ def set_axis_labels(ax, data, cax=None):
             # TODO: The "decision tree" for what gets plotted how and how
             # we check for that is still unfinished/not optimised
             how_to_plot = {'grid': plot_on_a_plain_grid,
-                           'equidistant': plot_on_a_plain_grid}
+                           'equidistant': plot_on_a_plain_grid,
+                           'point': plot_2d_scatterplot,
+                           'unknown': plot_2d_scatterplot}
 
-            log.debug('Plotting by id, determining plottype')
+            log.debug('Determining plottype')
             plottype = datatype_from_setpoints_2d([data[0]['data'],
                                                    data[1]['data']])
-
-            if plottype in how_to_plot.keys():
-                log.debug('Plotting by id, doing the actual plot')
-                xpoints = flatten_1D_data_for_plot(data[0]['data'])
-                ypoints = flatten_1D_data_for_plot(data[1]['data'])
-                zpoints = flatten_1D_data_for_plot(data[2]['data'])
-                ax, cbax = how_to_plot[plottype](xpoints, ypoints, zpoints, ax)
-                cbaxes.append(cbax)
-                set_axis_labels(ax, data, cbax)
-
-            else:
-                log.warning('2D data does not seem to be on a '
-                            'grid. Falling back to scatter plot')
-                xpoints = flatten_1D_data_for_plot(data[0]['data'])
-                ypoints = flatten_1D_data_for_plot(data[1]['data'])
-                zpoints = flatten_1D_data_for_plot(data[2]['data'])
-                mappable = ax.scatter(x=xpoints, y=ypoints, c=zpoints)
-                cbax = ax.figure.colorbar(mappable, ax=ax)
-                cbaxes.append(cbax)
-                set_axis_labels(ax, data, cbax)
+            log.debug(f'Plottype is: "f{plottype}".')
+            log.debug('Now doing the actual plot')
+            xpoints = flatten_1D_data_for_plot(data[0]['data'])
+            ypoints = flatten_1D_data_for_plot(data[1]['data'])
+            zpoints = flatten_1D_data_for_plot(data[2]['data'])
+            ax, cbax = how_to_plot[plottype](xpoints, ypoints, zpoints, ax)
+            cbaxes.append(cbax)
+            set_axis_labels(ax, data, cbax)
 
         else:
             log.warning('Multi-dimensional data encountered. '
-                       f'parameter {data[-1].name} depends on '
-                       f'{len(data-1)} parameters, cannot plot '
-                       f'that.')
+                        f'parameter {data[-1].name} depends on '
+                        f'{len(data-1)} parameters, cannot plot '
+                        f'that.')
             cbaxes.append(None)
 
     if len(axes) != len(cbaxes):
         raise RuntimeError("Non equal number of axes. Perhaps cbaxes is "
                            "missing from one of the cases above")
-    return axes,cbaxes
+    return axes, cbaxes
+
+
+def plot_2d_scatterplot(x: np.ndarray, y: np.ndarray, z: np.ndarray,
+                        ax: matplotlib.axes.Axes) -> AxesTuple:
+    """
+    Make a 2D scatterplot of the data
+
+    Args:
+        x: The x values
+        y: The y values
+        z: The z values
+        ax: The axis to plot onto
+
+    Returns:
+        The matplotlib axis handles for plot and colorbar
+    """
+    mappable = ax.scatter(x=x, y=y, c=z)
+    cbax = ax.figure.colorbar(mappable, ax=ax)
+    return ax, cbax
 
 
 def plot_on_a_plain_grid(x: np.ndarray, y: np.ndarray,
                          z: np.ndarray,
-                         ax: matplotlib.axes.Axes) -> Tuple[matplotlib.axes.Axes, matplotlib.axes.Axes]:
+                         ax: matplotlib.axes.Axes) -> AxesTuple:
     """
     Plot a heatmap of z using x and y as axes. Assumes that the data
     are rectangular, i.e. that x and y together describe a rectangular
@@ -165,7 +188,7 @@ def plot_on_a_plain_grid(x: np.ndarray, y: np.ndarray,
         x: The x values
         y: The y values
         z: The z values
-        ax: Optional the axis to plot into
+        ax: The axis to plot onto
 
     Returns:
         The matplotlib axes handle for plot and colorbar


Test patch: