Repository: microsoft/Qcodes

Issue:
String-valued parameters support: 3D data case in plot_by_id 
* Improve `plot_by_id` to use string-valued parameter values for tick labels for __3D plots__.
* Choose good plot types for 3D case (e.g. don't always use "scatter")
* Update example notebook for `plot_by_id` with 3D cases

This "issue" is a continuation of the development that started in https://github.com/QCoDeS/Qcodes/pull/1248.


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
@@ -218,11 +218,27 @@ def _all_in_group_or_subgroup(rows: np.ndarray) -> bool:
     return aigos
 
 
-def plottype_for_2d_data(xpoints: np.ndarray, ypoints: np.ndarray) -> str:
+def _strings_as_ints(inputarray: np.ndarray) -> np.ndarray:
     """
-    Determine plot type for 2D data by inspecting it
+    Return an integer-valued array version of a string-valued array. Maps, e.g.
+    array(['a', 'b', 'c', 'a', 'c']) to array([0, 1, 2, 0, 2]). Useful for
+    numerical setpoint analysis
+
+    Args:
+        inputarray: A 1D array of strings
+    """
+    newdata = np.zeros(len(inputarray))
+    for n, word in enumerate(np.unique(inputarray)):
+        newdata += ((inputarray == word).astype(int)*n)
+    return newdata
+
+
+def get_1D_plottype(xpoints: np.ndarray, ypoints: np.ndarray) -> str:
+    """
+    Determine plot type for a 1D plot by inspecting the data
 
     Possible plot types are:
+    * 'bar' - bar plot
     * 'point' - scatter plot
     * 'line' - line plot
 
@@ -233,11 +249,16 @@ def plottype_for_2d_data(xpoints: np.ndarray, ypoints: np.ndarray) -> str:
     Returns:
         Determined plot type as a string
     """
+
+    if isinstance(xpoints[0], str) and not isinstance(ypoints[0], str):
+        if len(xpoints) == len(np.unique(xpoints)):
+            return 'bar'
+        else:
+            return 'point'
     if isinstance(xpoints[0], str) or isinstance(ypoints[0], str):
-        plottype = 'point'
+        return 'point'
     else:
-        plottype = datatype_from_setpoints_1d(xpoints)
-    return plottype
+        return datatype_from_setpoints_1d(xpoints)
 
 
 def datatype_from_setpoints_1d(setpoints: np.ndarray) -> str:
@@ -261,12 +282,11 @@ def datatype_from_setpoints_1d(setpoints: np.ndarray) -> str:
         return 'line'
 
 
-def plottype_for_3d_data(xpoints: np.ndarray,
-                         ypoints: np.ndarray,
-                         zpoints: np.ndarray
-                         ) -> str:
+def get_2D_plottype(xpoints: np.ndarray,
+                    ypoints: np.ndarray,
+                    zpoints: np.ndarray) -> str:
     """
-    Determine plot type for 3D data by inspecting it
+    Determine plot type for a 2D plot by inspecting the data
 
     Plot types are:
     * 'grid' - colormap plot for data that is on a grid
@@ -283,12 +303,8 @@ def plottype_for_3d_data(xpoints: np.ndarray,
     Returns:
         Determined plot type as a string
     """
-    if isinstance(xpoints[0], str) \
-            or isinstance(ypoints[0], str) \
-            or isinstance(zpoints[0], str):
-        plottype = 'point'
-    else:
-        plottype = datatype_from_setpoints_2d(xpoints, ypoints)
+
+    plottype = datatype_from_setpoints_2d(xpoints, ypoints)
     return plottype
 
 
@@ -313,6 +329,12 @@ def datatype_from_setpoints_2d(xpoints: np.ndarray,
     Returns:
         A string with the name of the determined plot type
     """
+    # We represent categorical data as integer-valued data
+    if isinstance(xpoints[0], str):
+        xpoints = _strings_as_ints(xpoints)
+    if isinstance(ypoints[0], str):
+        ypoints = _strings_as_ints(ypoints)
+
     # First check whether all setpoints are identical along
     # any dimension
     x_all_the_same = np.allclose(xpoints, xpoints[0])
@@ -347,24 +369,26 @@ def datatype_from_setpoints_2d(xpoints: np.ndarray,
     return 'unknown'
 
 
-def reshape_2D_data(x: np.ndarray, y: np.ndarray,
-                    z: np.ndarray) -> Tuple[np.ndarray, np.ndarray,
-                                            np.ndarray]:
+def reshape_2D_data(x: np.ndarray, y: np.ndarray, z: np.ndarray
+                    ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
     xrow = np.array(_rows_from_datapoints(x)[0])
     yrow = np.array(_rows_from_datapoints(y)[0])
     nx = len(xrow)
     ny = len(yrow)
 
-
     # potentially slow method of filling in the data, should be optimised
     log.debug('Sorting 2D data onto grid')
-    z_to_plot = np.full((ny, nx), np.nan)
+
+    if isinstance(z[0], str):
+        z_to_plot = np.full((ny, nx), '', dtype=z.dtype)
+    else:
+        z_to_plot = np.full((ny, nx), np.nan)
     x_index = np.zeros_like(x, dtype=np.int)
     y_index = np.zeros_like(y, dtype=np.int)
     for i, xval in enumerate(xrow):
-        x_index[np.where(x==xval)[0]] = i
+        x_index[np.where(x == xval)[0]] = i
     for i, yval in enumerate(yrow):
-        y_index[np.where(y==yval)[0]] = i
+        y_index[np.where(y == yval)[0]] = i
 
     z_to_plot[y_index, x_index] = z
 
diff --git a/qcodes/dataset/measurements.py b/qcodes/dataset/measurements.py
--- a/qcodes/dataset/measurements.py
+++ b/qcodes/dataset/measurements.py
@@ -32,8 +32,10 @@ class ParameterTypeError(Exception):
 
 def is_number(thing: Any) -> bool:
     """
-    Test if an object can be converted to a number
+    Test if an object can be converted to a number UNLESS it is a string
     """
+    if isinstance(thing, str):
+        return False
     try:
         float(thing)
         return True
diff --git a/qcodes/dataset/plotting.py b/qcodes/dataset/plotting.py
--- a/qcodes/dataset/plotting.py
+++ b/qcodes/dataset/plotting.py
@@ -13,9 +13,9 @@
 from qcodes.dataset.data_set import load_by_id
 from qcodes.utils.plotting import auto_color_scale_from_config
 
-from .data_export import get_data_by_id, flatten_1D_data_for_plot
-from .data_export import (plottype_for_2d_data,
-                          plottype_for_3d_data, reshape_2D_data)
+from .data_export import (get_data_by_id, flatten_1D_data_for_plot,
+                          get_1D_plottype, get_2D_plottype, reshape_2D_data,
+                          _strings_as_ints)
 
 log = logging.getLogger(__name__)
 DB = qc.config["core"]["db_location"]
@@ -36,6 +36,7 @@
 FIGURE_KWARGS.remove('kwargs')
 SUBPLOTS_KWARGS = SUBPLOTS_OWN_KWARGS.union(FIGURE_KWARGS)
 
+
 def plot_by_id(run_id: int,
                axes: Optional[Union[matplotlib.axes.Axes,
                               Sequence[matplotlib.axes.Axes]]]=None,
@@ -96,8 +97,9 @@ def plot_by_id(run_id: int,
 
     Config dependencies: (qcodesrc.json)
     """
+
     # handle arguments and defaults
-    subplots_kwargs = {k:kwargs.pop(k)
+    subplots_kwargs = {k: kwargs.pop(k)
                        for k in set(kwargs).intersection(SUBPLOTS_KWARGS)}
 
     # Retrieve info about the run for the title
@@ -141,7 +143,8 @@ def plot_by_id(run_id: int,
             xpoints = data[0]['data']
             ypoints = data[1]['data']
 
-            plottype = plottype_for_2d_data(xpoints, ypoints)
+            plottype = get_1D_plottype(xpoints, ypoints)
+            log.debug(f'Determined plottype: {plottype}')
 
             if plottype == 'line':
                 # sort for plotting
@@ -152,6 +155,8 @@ def plot_by_id(run_id: int,
                 ax.plot(xpoints, ypoints, **kwargs)
             elif plottype == 'point':
                 ax.scatter(xpoints, ypoints, **kwargs)
+            elif plottype == 'bar':
+                ax.bar(xpoints, ypoints, **kwargs)
             else:
                 raise ValueError('Unknown plottype. Something is way wrong.')
 
@@ -171,17 +176,18 @@ def plot_by_id(run_id: int,
             # 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,
-                           'point': plot_2d_scatterplot,
-                           'unknown': plot_2d_scatterplot}
-
             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'])
 
-            plottype = plottype_for_3d_data(xpoints, ypoints, zpoints)
+            plottype = get_2D_plottype(xpoints, ypoints, zpoints)
 
+            log.debug(f'Determined plottype: {plottype}')
+
+            how_to_plot = {'grid': plot_on_a_plain_grid,
+                           'equidistant': plot_on_a_plain_grid,
+                           'point': plot_2d_scatterplot,
+                           'unknown': plot_2d_scatterplot}
             plot_func = how_to_plot[plottype]
 
             if colorbar is None and 'cmap' not in kwargs:
@@ -269,23 +275,31 @@ def plot_2d_scatterplot(x: np.ndarray, y: np.ndarray, z: np.ndarray,
     else:
         rasterized = len(z) > qc.config.plotting.rasterize_threshold
 
-    z_is_string_valued = isinstance(z[0], str)
+    z_is_stringy = isinstance(z[0], str)
 
-    if z_is_string_valued:
-        z_int = list(range(len(z)))
-        mappable = ax.scatter(x=x, y=y, c=z_int,
-                              rasterized=rasterized,**kwargs)
-    else:
-        mappable = ax.scatter(x=x, y=y, c=z,
-                              rasterized=rasterized, **kwargs)
+    if z_is_stringy:
+        z_strings = np.unique(z)
+        z = _strings_as_ints(z)
+
+    cmap = kwargs.pop('cmap') if 'cmap' in kwargs else None
+
+    if z_is_stringy:
+        name = cmap.name if hasattr(cmap, 'name') else 'viridis'
+        cmap = matplotlib.cm.get_cmap(name, len(z_strings))
+
+    mappable = ax.scatter(x=x, y=y, c=z,
+                          rasterized=rasterized, cmap=cmap, **kwargs)
 
     if colorbar is not None:
         colorbar = ax.figure.colorbar(mappable, ax=ax, cax=colorbar.ax)
     else:
         colorbar = ax.figure.colorbar(mappable, ax=ax)
 
-    if z_is_string_valued:
-        colorbar.ax.set_yticklabels(z)
+    if z_is_stringy:
+        N = len(z_strings)
+        f = (N-1)/N
+        colorbar.set_ticks([(n+0.5)*f for n in range(N)])
+        colorbar.set_ticklabels(z_strings)
 
     return ax, colorbar
 
@@ -320,6 +334,24 @@ def plot_on_a_plain_grid(x: np.ndarray,
         The matplotlib axes handle for plot and colorbar
     """
 
+    log.debug(f'Got kwargs: {kwargs}')
+
+    x_is_stringy = isinstance(x[0], str)
+    y_is_stringy = isinstance(y[0], str)
+    z_is_stringy = isinstance(z[0], str)
+
+    if x_is_stringy:
+        x_strings = np.unique(x)
+        x = _strings_as_ints(x)
+
+    if y_is_stringy:
+        y_strings = np.unique(y)
+        y = _strings_as_ints(y)
+
+    if z_is_stringy:
+        z_strings = np.unique(z)
+        z = _strings_as_ints(z)
+
     xrow, yrow, z_to_plot = reshape_2D_data(x, y, z)
 
     # we use a general edge calculator,
@@ -339,14 +371,37 @@ def plot_on_a_plain_grid(x: np.ndarray,
         rasterized = len(x_edges) * len(y_edges) \
                       > qc.config.plotting.rasterize_threshold
 
+    cmap = kwargs.pop('cmap') if 'cmap' in kwargs else None
+
+    if z_is_stringy:
+        name = cmap.name if hasattr(cmap, 'name') else 'viridis'
+        cmap = matplotlib.cm.get_cmap(name, len(z_strings))
+
     colormesh = ax.pcolormesh(x_edges, y_edges,
                               np.ma.masked_invalid(z_to_plot),
                               rasterized=rasterized,
+                              cmap=cmap,
                               **kwargs)
+
+    if x_is_stringy:
+        ax.set_xticks(np.arange(len(np.unique(x_strings))))
+        ax.set_xticklabels(x_strings)
+
+    if y_is_stringy:
+        ax.set_yticks(np.arange(len(np.unique(y_strings))))
+        ax.set_yticklabels(y_strings)
+
     if colorbar is not None:
         colorbar = ax.figure.colorbar(colormesh, ax=ax, cax=colorbar.ax)
     else:
         colorbar = ax.figure.colorbar(colormesh, ax=ax)
+
+    if z_is_stringy:
+        N = len(z_strings)
+        f = (N-1)/N
+        colorbar.set_ticks([(n+0.5)*f for n in range(N)])
+        colorbar.set_ticklabels(z_strings)
+
     return ax, colorbar
 
 


Test patch: