Repository: pyqtgraph/pyqtgraph

Issue:
Error bars must be also exported with .csv export
I like the option to export a plot to a .csv so I can make an alternative plot offline (i.e. matplotlib).

However, the .csv export only exports x-,y- and e.g. model. Without the uncertainties, this export is meaningless.
I understand that not all pyqtgraph plots use the "ErrorBarItem", but when it does, the errors must be exported too.

Please, include this in pyqtgraph! :-)



Discussion:
This sounds reasonable to me, but I'm not sure how exactly it should work. What colums would you expect there to be in a CSV export? An `ErrorBarItem` is not necessarily coupled with a `ScatterPlotItem` or `PlotCurveItem`, though the CSV exporter already has to make some assumptions to make something like the (x,y,y,y) columnMode work. So I could see `ErrorBarItem` adding only the height, for example, where the assumption then would be there's another item providing the y values and the errors are centered on them. Another possibility might be the lower and upper values of the bars, though this would be a little trickier to implement with the current exporter setup.

In general, the exporter module could use help getting fleshed out. There's a lot of neat and useful things there but it's incomplete in some ways.
@ixjlyons Thanks for looking at this! If you have a plot like this: 


![ES1_new](https://user-images.githubusercontent.com/44244057/93521426-5818fb00-f930-11ea-8b0e-ce6c0f36ae43.png)
(FYI, the project is here: https://github.com/3fon3fonov/exostriker)

At a csv export, I would expect to have "x, y, err_y, x_model, y_model"
But since data can come from different sources (in this case telescopes) some sort of indication must also be provided, e.g.:

"x, y, err_y, inst_n, x_model, y_model"

inst_n, could be let's say an integer for a given dataset: blue 0, red 1,...

In my project I am a bit outdated, I use pyqtgraph 0.10, directly included as a package in the Exo-Striker. This is because I had a number of my own hacks to make pyqtgraph work for me. However, I was unable to make this work, even though I tried a number of times. 

Below I attach my own hack around the /exporters/Matplotlib.py , where I was able to export the errors in a matplotlib GUI window.
Perhaps something similar can be done? E.g. check "self.item.items[]"  ???
 

```
class MatplotlibExporter(Exporter):
    Name = "Matplotlib Window"
    windows = []
    def __init__(self, item):
        Exporter.__init__(self, item)
        
    def parameters(self):
        return None

    def cleanAxes(self, axl):
        if type(axl) is not list:
            axl = [axl]
        for ax in axl:
            if ax is None:
                continue
            for loc, spine in ax.spines.items():
                if loc in ['left', 'bottom']:
                    pass
                elif loc in ['right', 'top']:
                    spine.set_color('none')
                    # do not draw the spine
                else:
                    raise ValueError('Unknown spine location: %s' % loc)
                # turn off ticks when there is no spine
                ax.xaxis.set_ticks_position('bottom')
    
    def export(self, fileName=None):
        
        if isinstance(self.item, PlotItem):
            mpw = MatplotlibWindow()
            MatplotlibExporter.windows.append(mpw)

            stdFont = 'Arial'
            
            fig = mpw.getFigure()
            

            # get labels from the graphic item
            xlabel = self.item.axes['bottom']['item'].label.toPlainText()
            ylabel = self.item.axes['left']['item'].label.toPlainText()
            title = self.item.titleLabel.text

            ax = fig.add_subplot(111, title=title)
            ax.clear()
            self.cleanAxes(ax)
            #ax.grid(True)
 
            for indx, item in enumerate(self.item.curves):
                x, y = item.getData() 
                
                if x is None:
                    continue
 
                opts = item.opts

                pen = fn.mkPen(opts['pen'])
                if pen.style() == QtCore.Qt.NoPen:
                    linestyle = ''
                    zorder = 10
                else:
                    linestyle = '-'
                    if len(y) < 500:
                        zorder = -100
                    else:
                        zorder = 10
 
                    
                color = tuple([c/255. for c in fn.colorTuple(pen.color())])
                symbol = opts['symbol']
                symbolPen = fn.mkPen(opts['symbolPen'])
                symbolBrush = fn.mkBrush(opts['symbolBrush'])
                markeredgecolor = tuple([c/255. for c in fn.colorTuple(symbolPen.color())])
                markerfacecolor = tuple([c/255. for c in fn.colorTuple(symbolBrush.color())])
                markersize = opts['symbolSize']
             
                if opts['fillLevel'] is not None and opts['fillBrush'] is not None:
                    fillBrush = fn.mkBrush(opts['fillBrush'])
                    fillcolor = tuple([c/255. for c in fn.colorTuple(fillBrush.color())])
                    ax.fill_between(x=x, y1=y, y2=opts['fillLevel'], facecolor=fillcolor)
                
                if symbol == 't':
                    symbol = 'v'
                elif symbol == 't1':
                    symbol = '^'
                elif symbol == 't2':
                    symbol = '>'
                elif symbol == 't3':
                    symbol = '<'
                elif symbol == 'star':
                    symbol = '*'
                    
 
                ax.plot(x, y, marker=symbol, color=color, linewidth=pen.width(), 
                        linestyle=linestyle, 
                        markeredgecolor=markeredgecolor, 
                        markerfacecolor=markerfacecolor,
                        markersize=markersize, zorder = zorder)

 
                xr, yr = self.item.viewRange()
                ax.set_xbound(*xr)
                ax.set_ybound(*yr)
                
                
            for indx, item in enumerate(self.item.items):

                if self.item.items[indx].__class__.__name__ == "InfiniteLine":
                    level = self.item.items[indx].value() 
                    Inf_line_color = self.item.items[indx].pen.color().name()

                    if Inf_line_color == '#000000': 
                        linestyle="--" 
                    else: 
                        linestyle="-"
                    ax.axhline(y=level, linewidth=0.8, linestyle=linestyle, color=Inf_line_color, zorder=-30)
                    
                    continue   
               
                if self.item.items[indx].__class__.__name__ == "FillBetweenItem":
                    x1,y1 = self.item.items[indx].curves[0].getData()
                    x2,y2 = self.item.items[indx].curves[1].getData()
        
                    ax.fill_between(x=x1, y1=y1, y2=y2, facecolor="#f48c42" )#fn.mkColor(244,140,66,128))

                    continue

                if self.item.items[indx].__class__.__name__ == "TextItem":
                    continue

                
                if "top" in self.item.items[indx].opts:
                    yerr=self.item.items[indx].opts["top"]
                    ax.errorbar(x=self.item.items[indx].opts["x"], y=self.item.items[indx].opts["y"],
                                     yerr=yerr, linestyle='', marker='o',markersize=0.5, linewidth=1.0, 
                                     color=self.item.items[indx].opts["pen"], capsize = 0, elinewidth=1,mew=0.0, zorder=-10)

                if "left" in self.item.items[indx].opts:
                    xerr=self.item.items[indx].opts["left"]
                    ax.errorbar(x=self.item.items[indx].opts["x"], y=self.item.items[indx].opts["y"],
                                     xerr=xerr, linestyle='', marker='o',markersize=0.5, linewidth=1.0, 
                                     color=self.item.items[indx].opts["pen"], capsize = 0, elinewidth=1,mew=0.0, zorder=-10)  

            ax.set_xlabel(xlabel)  # place the labels.
            ax.set_ylabel(ylabel)

            ax.spines['top'].set_color('k')
            ax.spines['right'].set_color('k')

            mpw.draw()
        else:
            raise Exception("Matplotlib export currently only works with plot items")
```



This gets quite important for me. Any help from the developers? Thanks!
Sorry @3fon3fonov we haven't had a chance to look at this at all; I know a number of the exporters have some issues that need addressing.

Best I can offer is if you think you know how this can be fixed, to submit a PR, and someone should look over that.  Our time to address new PRs is getting pretty good now.  I know that doesn't really help you right now, but it's the best I can offer if you need a short term fix.
Dear @j9ac9k

You motivated me a bit, so I did my own fix, which exports data errors in the x and the y-axis (if such are present). I made a pull request, but I consider my fix as a convenient hack, whereas I am sure it could be done more elegantly by more experienced people such as you.

![Exo-Striker_csv_export](https://user-images.githubusercontent.com/44244057/119804122-58ff2500-bee0-11eb-8131-29e3747a4c4e.gif)


> I made a pull request, but I consider my fix as a convenient hack, whereas I am sure it could be done more elegantly by more experienced people such as you.

Have you not seen any of my PRs? 😂

Thanks for the PR, I'll look at it today?

Resolving patch:
diff --git a/pyqtgraph/exporters/CSVExporter.py b/pyqtgraph/exporters/CSVExporter.py
--- a/pyqtgraph/exporters/CSVExporter.py
+++ b/pyqtgraph/exporters/CSVExporter.py
@@ -1,4 +1,9 @@
-from .. import PlotItem
+import csv
+import itertools
+
+import numpy as np
+
+from .. import ErrorBarItem, PlotItem
 from ..parametertree import Parameter
 from ..Qt import QtCore
 from .Exporter import Exporter
@@ -16,71 +21,107 @@ def __init__(self, item):
         self.params = Parameter(name='params', type='group', children=[
             {'name': 'separator', 'title': translate("Exporter", 'separator'), 'type': 'list', 'value': 'comma', 'limits': ['comma', 'tab']},
             {'name': 'precision', 'title': translate("Exporter", 'precision'), 'type': 'int', 'value': 10, 'limits': [0, None]},
-            {'name': 'columnMode', 'title': translate("Exporter", 'columnMode'), 'type': 'list', 'limits': ['(x,y) per plot', '(x,y,y,y) for all plots']}
+            {
+                'name': 'columnMode',
+                'title': translate("Exporter", 'columnMode'),
+                'type': 'list',
+                'limits': ['(x,y) per plot', '(x,y,y,y) for all plots']
+            }
         ])
-        
+
+        self.index_counter = itertools.count(start=0)
+        self.header = []
+        self.data = []
+
     def parameters(self):
         return self.params
-    
+
+    def _exportErrorBarItem(self, errorBarItem: ErrorBarItem) -> None:
+        error_data = []
+        index = next(self.index_counter)
+
+        # make sure the plot actually has data:
+        if errorBarItem.opts['x'] is None or errorBarItem.opts['y'] is None:
+            return None
+
+        header_naming_map = {
+            "left": "x_min_error",
+            "right": "x_max_error",
+            "bottom": "y_min_error",
+            "top": "y_max_error"
+        }
+
+        # grab the base-points
+        self.header.extend([f'x{index:04}_error', f'y{index:04}_error'])
+        error_data.extend([errorBarItem.opts['x'], errorBarItem.opts['y']])
+
+        # grab the error bars
+        for error_direction, header_label in header_naming_map.items():
+            if (error := errorBarItem.opts[error_direction]) is not None:
+                self.header.extend([f'{header_label}_{index:04}'])
+                error_data.append(error)
+
+        self.data.append(tuple(error_data))
+        return None
+
+    def _exportPlotDataItem(self, plotDataItem) -> None:
+        if hasattr(plotDataItem, 'getOriginalDataset'):
+            # try to access unmapped, unprocessed data
+            cd = plotDataItem.getOriginalDataset()
+        else:
+             # fall back to earlier access method
+            cd = plotDataItem.getData()
+        if cd[0] is None:
+            # no data found, break out...
+            return None
+        self.data.append(cd)
+
+        index = next(self.index_counter)
+        if plotDataItem.name() is not None:
+            name = plotDataItem.name().replace('"', '""') + '_'
+            xName = f"{name}x"
+            yName = f"{name}y"
+        else:
+            xName = f'x{index:04}'
+            yName = f'y{index:04}'
+        appendAllX = self.params['columnMode'] == '(x,y) per plot'
+        if appendAllX or index == 0:
+            self.header.extend([xName, yName])
+        else:
+            self.header.extend([yName])
+        return None
+
     def export(self, fileName=None):
-        
         if not isinstance(self.item, PlotItem):
-            raise Exception("Must have a PlotItem selected for CSV export.")
-        
+            raise TypeError("Must have a PlotItem selected for CSV export.")
+
         if fileName is None:
             self.fileSaveDialog(filter=["*.csv", "*.tsv"])
             return
 
-        data = []
-        header = []
+        for item in self.item.items:
+            if isinstance(item, ErrorBarItem):
+                self._exportErrorBarItem(item)
+            elif hasattr(item, 'implements') and item.implements('plotData'):
+                self._exportPlotDataItem(item)
 
-        appendAllX = self.params['columnMode'] == '(x,y) per plot'
+        sep = "," if self.params['separator'] == 'comma' else "\t"
+        # we want to flatten the nested arrays of data into columns
+        columns = [column for dataset in self.data for column in dataset]
+        with open(fileName, 'w', newline='') as csvfile:
+            writer = csv.writer(csvfile, delimiter=sep, quoting=csv.QUOTE_MINIMAL)
+            writer.writerow(self.header)
+            for row in itertools.zip_longest(*columns, fillvalue=""):
+                row_to_write = [
+                    item if isinstance(item, str) 
+                    else np.format_float_positional(
+                        item, precision=self.params['precision']
+                    )
+                    for item in row
+                ]
+                writer.writerow(row_to_write)
 
-        for i, c in enumerate(self.item.curves):
-            if hasattr(c, 'getOriginalDataset'): # try to access unmapped, unprocessed data
-                cd = c.getOriginalDataset()
-            else:
-                cd = c.getData() # fall back to earlier access method
-            if cd[0] is None:
-                continue
-            data.append(cd)
-            if hasattr(c, 'implements') and c.implements('plotData') and c.name() is not None:
-                name = c.name().replace('"', '""') + '_'
-                xName, yName = '"'+name+'x"', '"'+name+'y"'
-            else:
-                xName = 'x%04d' % i
-                yName = 'y%04d' % i
-            if appendAllX or i == 0:
-                header.extend([xName, yName])
-            else:
-                header.extend([yName])
-
-        if self.params['separator'] == 'comma':
-            sep = ','
-        else:
-            sep = '\t'
-
-        with open(fileName, 'w') as fd:
-            fd.write(sep.join(map(str, header)) + '\n')
-            i = 0
-            numFormat = '%%0.%dg' % self.params['precision']
-            numRows = max([len(d[0]) for d in data])
-            for i in range(numRows):
-                for j, d in enumerate(data):
-                    # write x value if this is the first column, or if we want
-                    # x for all rows
-                    if appendAllX or j == 0:
-                        if d is not None and i < len(d[0]):
-                            fd.write(numFormat % d[0][i] + sep)
-                        else:
-                            fd.write(' %s' % sep)
-
-                    # write y value
-                    if d is not None and i < len(d[1]):
-                        fd.write(numFormat % d[1][i] + sep)
-                    else:
-                        fd.write(' %s' % sep)
-                fd.write('\n')
-
-
-CSVExporter.register()        
+        self.header.clear()
+        self.data.clear()
+
+CSVExporter.register()


Test patch:
diff --git a/tests/exporters/test_csv.py b/tests/exporters/test_csv.py
--- a/tests/exporters/test_csv.py
+++ b/tests/exporters/test_csv.py
@@ -1,12 +1,11 @@
 """
 CSV export test
 """
-from __future__ import absolute_import, division, print_function
-
 import csv
 import tempfile
 
 import numpy as np
+import pytest
 
 import pyqtgraph as pg
 
@@ -54,3 +53,31 @@ def test_CSVExporter():
 
             assert (i >= len(x3) and vals[4] == '') or approxeq(float(vals[4]), x3[i])
             assert (i >= len(y3) and vals[5] == '') or approxeq(float(vals[5]), y3[i])
+
+def test_CSVExporter_with_ErrorBarItem():
+    plt = pg.plot()
+    x=np.arange(5)
+    y=np.array([1, 2, 3, 2, 1])
+    top_error = np.array([2, 3, 3, 3, 2])
+    bottom_error = np.array([-2.5, -2.5, -2.5, -2.5, -1.5])
+
+    err = pg.ErrorBarItem(
+        x=x,
+        y=y,
+        top=top_error,
+        bottom=bottom_error
+    )
+    plt.addItem(err)
+    ex = pg.exporters.CSVExporter(plt.plotItem)
+    with tempfile.NamedTemporaryFile(mode="w+t", suffix='.csv', encoding="utf-8", delete=False) as tf:
+        ex.export(fileName=tf.name)
+        lines = [line for line in csv.reader(tf)]
+
+    header = lines.pop(0)
+
+    assert header == ['x0000_error', 'y0000_error', 'y_min_error_0000', 'y_max_error_0000']
+    for i, values in enumerate(lines):
+        assert pytest.approx(float(values[0])) == x[i]
+        assert pytest.approx(float(values[1])) == y[i]
+        assert pytest.approx(float(values[2])) == bottom_error[i]
+        assert pytest.approx(float(values[3])) == top_error[i]