Repository: microsoft/Qcodes

Issue:
Cannot export multiindex dataframe to xarray
When exporting a `Dataset` with **non-unique** multiple indices to xarray, the below exception is raised:

```python
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-66-2f7d27cb9052> in <module>
      7     if len(ds):
      8         print(ds.run_id)
----> 9         xds = ds.to_xarray_dataset()

~/repos/Qcodes/qcodes/dataset/data_set.py in to_xarray_dataset(self, start, end, *params)
   1222                                        end=end)
   1223 
-> 1224         return self._load_to_xarray_dataset(data)
   1225 
   1226     def _load_to_xarray_dataset(self, data: ParameterData) -> "xr.Dataset":

~/repos/Qcodes/qcodes/dataset/data_set.py in _load_to_xarray_dataset(self, data)
   1235             )
   1236 
-> 1237         data_xrdarray_dict = self._load_to_xarray_dataarray_dict(data)
   1238 
   1239         # Casting Hashable for the key type until python/mypy#1114

~/repos/Qcodes/qcodes/dataset/data_set.py in _load_to_xarray_dataarray_dict(self, datadict)
   1307         for name, subdict in datadict.items():
   1308             index = self._generate_pandas_index(subdict)
-> 1309             xrdarray: xr.DataArray = self._data_to_dataframe(
   1310                 subdict, index).to_xarray()[name]
   1311             paramspec_dict = self.paramspecs[name]._to_dict()

~/anaconda3/envs/bringup/lib/python3.9/site-packages/pandas/core/generic.py in to_xarray(self)
   3014             return xarray.DataArray.from_series(self)
   3015         else:
-> 3016             return xarray.Dataset.from_dataframe(self)
   3017 
   3018     @final

~/anaconda3/envs/bringup/lib/python3.9/site-packages/xarray/core/dataset.py in from_dataframe(cls, dataframe, sparse)
   5104 
   5105         if isinstance(idx, pd.MultiIndex) and not idx.is_unique:
-> 5106             raise ValueError(
   5107                 "cannot convert a DataFrame with a non-unique MultiIndex into xarray"
   5108             )

ValueError: cannot convert a DataFrame with a non-unique MultiIndex into xarray
```

An easy solution to this would be to add `.reset_index()` to line 1309 in data_set.py.

### System
WSL2, Ubuntu 20.04.1 LTS

**qcodes branch**
`master`

**qcodes commit**
`eaee80285a507050165798d4af0271f373195a41`


Discussion:

Resolving patch:
diff --git a/qcodes/dataset/data_set.py b/qcodes/dataset/data_set.py
--- a/qcodes/dataset/data_set.py
+++ b/qcodes/dataset/data_set.py
@@ -1246,8 +1246,15 @@ def _load_to_xarray_dataset(self, data: ParameterData) -> "xr.Dataset":
                 paramspec_dict = self.paramspecs[str(dim)]._to_dict()
                 xrdataset.coords[str(dim)].attrs.update(paramspec_dict.items())
 
-        xrdataset.attrs["sample_name"] = self.sample_name
-        xrdataset.attrs["exp_name"] = self.exp_name
+        xrdataset.attrs.update({
+            "sample_name": self.sample_name,
+            "exp_name": self.exp_name,
+            "snapshot": json.dumps(self.snapshot),
+            "guid": self.guid,
+            "run_timestamp": self.run_timestamp() or "",
+            "completed_timestamp": self.completed_timestamp() or "",
+            "run_id": self.run_id
+        })
 
         return xrdataset
 
@@ -1306,11 +1313,18 @@ def _load_to_xarray_dataarray_dict(self,
 
         for name, subdict in datadict.items():
             index = self._generate_pandas_index(subdict)
-            xrdarray: xr.DataArray = self._data_to_dataframe(
-                subdict, index).to_xarray()[name]
-            paramspec_dict = self.paramspecs[name]._to_dict()
-            xrdarray.attrs.update(paramspec_dict.items())
-            data_xrdarray_dict[name] = xrdarray
+            if index is not None and len(index.unique()) != len(index):
+                for _name in subdict:
+                    data_xrdarray_dict[_name] = self._data_to_dataframe(
+                        subdict, index).reset_index().to_xarray()[_name]
+                    paramspec_dict = self.paramspecs[_name]._to_dict()
+                    data_xrdarray_dict[_name].attrs.update(paramspec_dict.items())
+            else:
+                xrdarray: xr.DataArray = self._data_to_dataframe(
+                    subdict, index).to_xarray()[name]
+                data_xrdarray_dict[name] = xrdarray
+                paramspec_dict = self.paramspecs[name]._to_dict()
+                xrdarray.attrs.update(paramspec_dict.items())
 
         return data_xrdarray_dict
 
@@ -1343,6 +1357,8 @@ def write_data_to_text_file(self, path: str,
             single_file: If true, merges the data of same length of multiple
                          dependent parameters to a single file.
             single_file_name: User defined name for the data to be concatenated.
+                              If no extension is passed (.dat, .csv or .txt),
+                              .dat is automatically appended.
 
         Raises:
             DataLengthException: If the data of multiple parameters have not same


Test patch:
diff --git a/qcodes/tests/dataset/test_dataset_basic.py b/qcodes/tests/dataset/test_dataset_basic.py
--- a/qcodes/tests/dataset/test_dataset_basic.py
+++ b/qcodes/tests/dataset/test_dataset_basic.py
@@ -4,6 +4,7 @@
 from copy import copy
 from typing import Dict, List, Optional, Sequence, Tuple
 from unittest.mock import patch
+import json
 
 import hypothesis.strategies as hst
 import numpy as np
@@ -1286,6 +1287,24 @@ def _make_mock_dataset():
     return dataset
 
 
+@pytest.mark.usefixtures('experiment')
+@pytest.fixture(name="mock_dataset_nonunique")
+def _make_mock_dataset_nonunique_index():
+    dataset = new_data_set("dataset")
+    xparam = ParamSpecBase("x", 'numeric')
+    yparam = ParamSpecBase("y", 'numeric')
+    zparam = ParamSpecBase("z", 'numeric')
+    idps = InterDependencies_(
+        dependencies={yparam: (xparam,), zparam: (xparam,)})
+    dataset.set_interdependencies(idps)
+
+    dataset.mark_started()
+    results = [{'x': 0, 'y': 1, 'z': 2}, {'x': 0, 'y': 1, 'z': 2}]
+    dataset.add_results(results)
+    dataset.mark_completed()
+    return dataset
+
+
 @pytest.mark.usefixtures('experiment')
 def test_write_data_to_text_file_save(tmp_path_factory):
     dataset = new_data_set("dataset")
@@ -1426,3 +1445,29 @@ def test_same_setpoint_warning_for_df_and_xarray(different_setpoint_dataset):
 
     with pytest.warns(UserWarning, match=warning_message):
         different_setpoint_dataset.cache.to_xarray_dataset()
+
+
+def test_export_to_xarray(mock_dataset, mock_dataset_nonunique):
+    ds = mock_dataset.to_xarray_dataset()
+    assert len(ds) == 2
+    assert "index" not in ds.coords
+    assert "x" in ds.coords
+    assert ds.guid == mock_dataset.guid
+    assert ds.sample_name == mock_dataset.sample_name
+    assert ds.exp_name == mock_dataset.exp_name
+    assert ds.snapshot == json.dumps(mock_dataset.snapshot)
+    assert ds.run_timestamp == mock_dataset.run_timestamp()
+    assert ds.completed_timestamp == mock_dataset.completed_timestamp()
+    assert ds.run_id == mock_dataset.run_id
+
+    ds = mock_dataset_nonunique.to_xarray_dataset()
+    assert len(ds) == 3
+    assert "index" in ds.coords
+    assert "x" not in ds.coords
+    assert ds.guid == mock_dataset_nonunique.guid
+    assert ds.sample_name == mock_dataset_nonunique.sample_name
+    assert ds.exp_name == mock_dataset_nonunique.exp_name
+    assert ds.snapshot == json.dumps(mock_dataset_nonunique.snapshot)
+    assert ds.run_timestamp == mock_dataset_nonunique.run_timestamp()
+    assert ds.completed_timestamp == mock_dataset_nonunique.completed_timestamp()
+    assert ds.run_id == mock_dataset_nonunique.run_id