Repository: wright-group/WrightTools

Issue:
Support export to csv
There is still demand within the user group to have an easy way to export wt5 data to work with e.g. Excel.  

This could be a Data method (`data.to_csv(filename)`), could also be an entry-point?  e.g. `wt-to-csv --filename data.wt5`.




Discussion:
I am of the opinion that data objects which have channels which are not 1D are difficult to export into ASCII files to then be interacted with by Excel. Non-1D channels are doable (i.e. it would look like the reverse of `from_PyCMDS()`, but Excel interactions are a pain. 

If `data.to_ASCII()` is to be a thing, I believe that it should be preceded by a call to `data.drop()`[1] and then internally checked for 1D-ness. 


Currently, I have many colleagues that do not work with non-1D nor non-ASCII data sets. So I use the below code to write them metadata poor 1D ASCII files. These files seem to fulfill their needs.

```
import tidy_headers
import collections

def arr_to_ASCII(arr, filepath, column_names=None, column_units=None, transpose=True):
    """
    Write array to ASCII file.
    """
    # vstack is useful here arr = np.vstack(arrs1D)
    out_dict = collections.OrderedDict()
    if column_names != None:
        out_dict["columns"] = column_names
    if column_units != None:
        out_dict["units"] = column_units
    tidy_headers.write(filepath, out_dict)
    if transpose:
        arr = arr.T

    with open(filepath, "ab") as f:
        np.savetxt(f, arr, delimiter="\t")
```

[1] https://github.com/wright-group/WrightTools/issues/1069

Resolving patch:
diff --git a/WrightTools/data/_data.py b/WrightTools/data/_data.py
--- a/WrightTools/data/_data.py
+++ b/WrightTools/data/_data.py
@@ -11,6 +11,7 @@
 import warnings
 
 import numpy as np
+import sys
 
 import h5py
 
@@ -1928,6 +1929,94 @@ def transform(self, *axes, verbose=True):
         elif verbose and newt and not nownewt:
             print("I got better")
 
+    def translate_to_txt(
+        self, filepath, delimiter="\t", channels=None, variables=None, fmt=".5g", verbose=True
+    ):
+        """
+        Write a serialized, readable list of the channels and variables to
+        file. Each line (row) denotes a seperate data point. Each column
+        represents a unique variable or channel. Axes are neglected.
+
+        Parameters
+        ----------
+
+        filepath: path-like
+            path to output file.
+        delimiter: str
+            separation character.  Defaults to "\t"
+        channels: list[str] (optional)
+            List of channel names to include. Default is all channels.
+        variables: list[str] (optional)
+            List of variable names to include. Default is all variables.
+        fmt: [python format spec](https://docs.python.org/3/library/string.html#formatspec)
+            format specifier for variables and channels
+
+        Returns
+        -------
+
+        None
+
+        Notes
+        -----
+
+        * This is a lossy write procedure; some properties, such as axes, are
+        not recorded.
+        * The shape structure of the data is recorded as a series of indices
+        (`{a_i}`) comprising the first few columns. A vertical line separates
+        these indexes from variables and channels
+        * wt5, the native file format for Data objects, is a specific variant
+        of the HDF5 file format. HDF5 has well-developed tools for working with
+        generic datasets.  See
+        https://portal.hdfgroup.org/display/knowledge/How+to+convert+an+HDF4+or+HDF5+file+to+ASCII+%28text%29+or+Excel
+        for more information.
+        """
+        # attrs
+        import tidy_headers
+
+        tidy_headers.write(filepath, {k: v for k, v in self.attrs.items()})
+
+        columns = [f"a_{i}" for i in range(self.ndim)]
+        columns.append("|")
+        is_broadcast = []
+
+        variables = list(self.variable_names) if variables is None else variables
+        for var in variables:
+            columns.append(f"{var} ({self[var].units})")
+            is_broadcast.append([i == 1 for i in self[var].shape])
+
+        channels = list(self.channel_names) if channels is None else channels
+        for ch in channels:
+            columns.append(f"{ch} ({self[ch].units})")
+            is_broadcast.append([i == 1 for i in self[ch].shape])
+
+        with open(filepath, "a") as f:
+            f.write(delimiter.join(columns) + "\n")
+            chunk = ""
+            for i, ndi in enumerate(np.ndindex(self.shape)):
+                line = [str(i) for i in ndi]
+                line += ["|"]
+                for j, name in enumerate(variables + channels):
+                    arr = self[name]
+                    # broadcast reduced dimensions arrays to full
+                    idxs = tuple(xi * (not yi) for xi, yi in zip(ndi, is_broadcast[j]))
+                    line.append(f"{arr[idxs]:{fmt}}")
+                if verbose and ((i == 0) or (not (i % 10))):
+                    frac = round(i / self.size, 3)
+                    sys.stdout.write(
+                        f"[{'=' * int(frac * 60): <60}] {frac * 100:0.1f}% ...to_txt\r"
+                    )
+                    sys.stdout.flush()
+                chunk += delimiter.join(line) + "\n"
+                if (i % 100) == 99:  # write to disk periodically
+                    f.write(chunk)
+                    chunk = ""
+            if chunk:
+                f.write(chunk)
+
+        if verbose:
+            sys.stdout.write(f"[{'=' * 60}] {100:0.1f}% ...done! \r")
+            sys.stdout.flush()
+
     def set_constants(self, *constants, verbose=True):
         """Set the constants associated with the data.
 


Test patch:
diff --git a/tests/data/translate_to_txt.py b/tests/data/translate_to_txt.py
new file mode 100644
--- /dev/null
+++ b/tests/data/translate_to_txt.py
@@ -0,0 +1,41 @@
+"""Test transform."""
+
+
+# --- import --------------------------------------------------------------------------------------
+
+
+import pathlib
+import WrightTools as wt
+
+from WrightTools import datasets
+from tempfile import NamedTemporaryFile
+
+
+# --- tests ---------------------------------------------------------------------------------------
+
+
+def test_datasets_mos2():
+    d = wt.open(datasets.wt5.v1p0p1_MoS2_TrEE_movie).at(w2=[18000, "wn"])[:5]
+
+    with NamedTemporaryFile(delete=False) as tmp:
+        d.translate_to_txt(tmp.name, verbose=True)
+
+        with open(tmp.name, "r") as f:
+            for i in range(100):
+                f.readline()
+            datum_txt = f.readline().split("|")
+            id1, id2 = [_ for _ in map(int, datum_txt[0].split())]
+            values = [_ for _ in map(float, datum_txt[1].split())]
+            datum_wt5 = d[int(id1), int(id2)]
+            for i, vari in enumerate(d.variable_names):
+                # print(vari, values[i], datum_wt5[vari][:])
+                assert (values[i] - datum_wt5[vari][:]) ** 2 <= (1e-4 * values[i]) ** 2
+
+    d.close()
+
+
+# --- run -----------------------------------------------------------------------------------------
+
+
+if __name__ == "__main__":
+    test_datasets_mos2()