diff --git a/miniapp/miniapp.cpp b/miniapp/miniapp.cpp
index f267066ccb35093057527d4da8165d41015d1452..d5890c05d936da3d5ec6cef99256ac43c4f2c245 100644
--- a/miniapp/miniapp.cpp
+++ b/miniapp/miniapp.cpp
@@ -111,6 +111,7 @@ struct model {
             double value;
         };
         std::string name;
+        std::string unit;
         index_type id;
         std::vector<sample_type> samples;
     };
@@ -139,9 +140,9 @@ struct model {
     };
 
     mc::sampler make_simple_sampler(
-        index_type probe_gid, const std::string name, index_type id, float dt)
+        index_type probe_gid, const std::string& name, const std::string& unit, index_type id, float dt)
     {
-        traces.push_back(trace_data{name, id});
+        traces.push_back(trace_data{name, unit, id});
         return {probe_gid, simple_sampler_functor(traces, traces.size()-1, dt)};
     }
 
@@ -156,14 +157,20 @@ struct model {
             auto path = "trace_" + std::to_string(trace.id)
                       + "_" + trace.name + ".json";
 
-            nlohmann::json json;
-            json["name"] = trace.name;
+            nlohmann::json jrep;
+            jrep["name"] = trace.name;
+            jrep["units"] = trace.unit;
+            jrep["id"] = trace.id;
+ 
+            auto& jt = jrep["data"]["time"];
+            auto& jy = jrep["data"][trace.name];
+                 
             for (const auto& sample: trace.samples) {
-                json["time"].push_back(sample.time);
-                json["value"].push_back(sample.value);
+                jt.push_back(sample.time);
+                jy.push_back(sample.value);
             }
             std::ofstream file(path);
-            file << std::setw(1) << json << std::endl;
+            file << std::setw(1) << jrep << std::endl;
         }
     }
 };
@@ -344,8 +351,8 @@ void all_to_all_model(nest::mc::io::cl_options& options, model& m) {
     //  setup probes
     //
 
-        mc::util::profiler_leave();
-        mc::util::profiler_enter("probes");
+    mc::util::profiler_leave();
+    mc::util::profiler_enter("probes");
 
     // monitor soma and dendrite on a few cells
     float sample_dt = 0.1;
@@ -358,12 +365,16 @@ void all_to_all_model(nest::mc::io::cl_options& options, model& m) {
         auto lid = m.communicator.group_lid(gid);
         auto probe_soma = m.cell_groups[lid].probe_gid_range().first;
         auto probe_dend = probe_soma+1;
+        auto probe_dend_current = probe_soma+2;
 
         m.cell_groups[lid].add_sampler(
-            m.make_simple_sampler(probe_soma, "vsoma", gid, sample_dt)
+            m.make_simple_sampler(probe_soma, "vsoma", "mV", gid, sample_dt)
         );
         m.cell_groups[lid].add_sampler(
-            m.make_simple_sampler(probe_dend, "vdend", gid, sample_dt)
+            m.make_simple_sampler(probe_dend, "vdend", "mV", gid, sample_dt)
+        );
+        m.cell_groups[lid].add_sampler(
+            m.make_simple_sampler(probe_dend_current, "idend", "mA/cm²", gid, sample_dt)
         );
     }
 
@@ -421,10 +432,12 @@ mc::cell make_cell(int compartments_per_segment, int num_synapses) {
     // add probes: 
     auto probe_soma = cell.add_probe({0, 0}, mc::probeKind::membrane_voltage);
     auto probe_dendrite = cell.add_probe({1, 0.5}, mc::probeKind::membrane_voltage);
+    auto probe_dendrite_current = cell.add_probe({1, 0.5}, mc::probeKind::membrane_current);
 
     EXPECTS(probe_soma==0);
     EXPECTS(probe_dendrite==1);
-    (void)probe_soma, (void)probe_dendrite;
+    EXPECTS(probe_dendrite_current==2);
+    (void)probe_soma, (void)probe_dendrite, (void)probe_dendrite_current;
 
     return cell;
 }
diff --git a/scripts/tsplot.py b/scripts/tsplot.py
new file mode 100644
index 0000000000000000000000000000000000000000..a135b9fbc373a70231f4d42bc16f70cf4c9ce7b3
--- /dev/null
+++ b/scripts/tsplot.py
@@ -0,0 +1,323 @@
+#!env python
+
+import argparse
+import json
+import numpy as np
+import sys
+import re
+import matplotlib as M
+import matplotlib.pyplot as P
+from itertools import chain, islice, cycle
+
+# Read timeseries data from multiple files, plot each in one planel, with common
+# time axis, and optionally sharing a vertical axis as governed by the configuration.
+
+def parse_clargs():
+    def float_or_none(s):
+        try: return float(s)
+        except ValueError: return None
+
+    def parse_range_spec(s):
+        l, r = (float_or_none(x) for x in s.split(','))
+        return (l,r)
+
+    P = argparse.ArgumentParser(description='Plot time series data on one or more graphs.')
+    P.add_argument('inputs', metavar='FILE', nargs='+',
+                   help='time series data in JSON format')
+    P.add_argument('-t', '--abscissae', metavar='RANGE', dest='trange',
+                   type=parse_range_spec, 
+                   help='restrict time axis to RANGE (see below)')
+    P.add_argument('-g', '--group', metavar='KEY,...',  dest='groupby',
+                   type=lambda s: s.split(','), 
+                   help='plot series with same KEYs on the same axes')
+
+    P.add_argument('-o', '--out', metavar='FILE',  dest='outfile',
+                   help='save plot to file FILE')
+
+    P.epilog =  'A range is specifed by a pair of floating point numbers min,max where '
+    P.epilog += 'either may be omitted to indicate the minimum or maximum of the corresponding '
+    P.epilog += 'values in the data.'
+
+    # modify args to avoid argparse having a fit when it encounters an option
+    # argument of the form '<negative number>,...'
+
+    argsbis = [' '+a if re.match(r'-[\d.]',a) else a for a in sys.argv[1:]]
+    return P.parse_args(argsbis)
+
+def isstring(s):
+    return isinstance(s,str) or isinstance(s,unicode)
+
+def take(n, s):
+    return islice((i for i in s), 0, n)
+
+class TimeSeries:
+    def __init__(self, ts, ys, **kwargs):
+        self.t = np.array(ts)
+        n = self.t.shape[0]
+
+        self.y = np.full_like(self.t, np.nan)
+        ny = min(len(ys), len(self.y))
+        self.y[:ny] = ys[:ny]
+
+        self.meta = dict(kwargs)
+
+    def tclip(self, bounds):
+        clip = range_meet(self.trange(), bounds)
+        self.t = np.ma.masked_outside(self.t, v1=clip[0], v2=clip[1])
+        self.y = np.ma.masked_where(np.ma.getmask(self.t), self.y)
+
+    def name(self):
+        return self.meta.get('name',"")   # value of 'name' attribute in source
+
+    def label(self):
+        return self.meta.get('label',"")  # name of column in source
+
+    def units(self):
+        return self.meta.get('units',"")
+        
+    def trange(self):
+        return (min(self.t), max(self.t))
+
+    def yrange(self):
+        return (min(self.y), max(self.y))
+
+
+def read_json_timeseries(source):
+    j = json.load(source)
+
+    # Convention:
+    #
+    # Time series data is represented by an object with a subobject 'data' and optionally
+    # other key/value entries comprising metadata for the time series.
+    #
+    # The 'data' object holds one array of numbers 'time' and zero or more other
+    # numeric arrays of sample values corresponding to the values in 'time'. The
+    # names of these other arrays are taken to be the labels for the plots.
+    #
+    # Units can be specified by a top level entry 'units' which is either a string
+    # (units are common for all data series in the object) or by a map that
+    # takes a label to a unit string.
+
+    ts_list = []
+    jdata = j['data']
+    ncol = len(jdata)
+
+    times = jdata['time']
+    nsample = len(times)
+
+    def units(label):
+        try:
+           unitmap = j['units']
+           if isstring(unitmap):
+               return unitmap
+           else:
+               return unitmap[label]
+        except:
+           return ""
+
+    i = 1
+    for key in jdata.keys():
+        if key=="time": continue
+
+        meta = dict(j.items() + {'label': key, 'data': None, 'units': units(key)}.items())
+        del meta['data']
+
+        ts_list.append(TimeSeries(times, jdata[key], **meta))
+
+    return ts_list
+        
+def range_join(r, s):
+    return (min(r[0], s[0]), max(r[1], s[1]))
+
+def range_meet(r, s):
+    return (max(r[0], s[0]), min(r[1], s[1]))
+
+
+class PlotData:
+    def __init__(self, key_label=""):
+        self.series = []
+        self.group_key_label = key_label
+
+    def trange(self):
+        return reduce(range_join, [s.trange() for s in self.series])
+
+    def yrange(self):
+        return reduce(range_join, [s.yrange() for s in self.series])
+
+    def name(self):
+        return reduce(lambda n, s: n or s.name(), self.series, "")
+
+    def group_label(self):
+        return self.group_key_label
+
+    def unique_labels(self):
+        # attempt to create unique labels for plots in the group based on
+        # meta data
+        labels = [s.label() for s in self.series]
+        if len(labels)<2:
+            return labels
+
+        n = len(labels)
+        keyset = reduce(lambda k, s: k.union(s.meta.keys()), self.series, set())
+        keyi = iter(keyset)
+        try:
+            while len(set(labels)) != n:
+                k = next(keyi)
+                if k=='label':
+                    continue
+
+                vs = [s.meta.get(k,None) for s in self.series]
+                if len(set(vs))==1:
+                    continue
+
+                for i in xrange(n):
+                    prefix = '' if k=='name' else k+'='
+                    if vs[i] is not None:
+                        labels[i] += ', '+k+'='+str(vs[i])
+
+        except StopIteration:
+            pass
+
+        return labels
+
+# Input: list of TimeSeries objects; collection of metadata keys to group on
+# Return list of plot info (time series, data extents, metadata), one per plot.
+
+def gather_ts_plots(tss, groupby):
+    group_lookup = {}
+    plot_groups = []
+
+    for ts in tss:
+        key = tuple([ts.meta.get(g) for g in groupby])
+        if key is () or None in key or key not in group_lookup:
+            pretty_key=', '.join([str(k)+'='+str(v) for k,v in zip(groupby, key) if v is not None])
+            pd = PlotData(pretty_key)
+            pd.series = [ts]
+            plot_groups.append(pd)
+            group_lookup[key] = len(plot_groups)-1
+        else:
+            plot_groups[group_lookup[key]].series.append(ts)
+
+    return plot_groups
+
+
+def make_palette(cm_name, n, cmin=0, cmax=1):
+    smap = M.cm.ScalarMappable(M.colors.Normalize(cmin/float(cmin-cmax),(cmin-1)/float(cmin-cmax)),
+                               M.cm.get_cmap(cm_name))
+    return [smap.to_rgba((2*i+1)/float(2*n)) for i in xrange(n)]
+
+def plot_plots(plot_groups, save=None):
+    nplots = len(plot_groups)
+    plot_groups = sorted(plot_groups, key=lambda g: g.group_label())
+
+    # use same global time scale for all plots
+    trange = reduce(range_join, [g.trange() for g in plot_groups])
+
+    # use group names for titles?
+    group_titles = any((g.group_label() for g in plot_groups))
+
+    figure = P.figure()
+    for i in xrange(nplots):
+        group = plot_groups[i]
+        plot = figure.add_subplot(nplots, 1, i+1)
+
+        title = group.group_label() if group_titles else group.name()
+        plot.set_title(title)
+
+        # y-axis label: use timeseries label and units if only
+        # one series in group, otherwise use a legend with labels,
+        # and units alone on the axes. At most two different unit
+        # axes can be drawn.
+
+        def ylabel(unit):
+            if len(group.series)==1:
+                lab = group.series[0].label()
+                if unit:
+                    lab += ' (' + unit + ')'
+            else:
+                lab = unit
+
+            return lab
+            
+        uniq_units = list(set([s.units() for s in group.series]))
+        uniq_units.sort()
+        if len(uniq_units)>2:
+            logger.warning('more than two different units on the same plot')
+            uniq_units = uniq_units[:2]
+
+        # store each series in a slot corresponding to one of the units,
+        # together with a best-effort label
+
+        series_by_unit = [[] for i in xrange(len(uniq_units))]
+        unique_labels = group.unique_labels()
+
+        for si in xrange(len(group.series)):
+            s = group.series[si]
+            label = unique_labels[si]
+            try:
+                series_by_unit[uniq_units.index(s.units())].append((s,label))
+            except ValueError:
+                pass
+
+        # TODO: need to find a scheme of colour/line allocation that is
+        # double y-axis AND greyscale friendly.
+
+        palette = \
+            [make_palette(cm, n, 0, 0.5) for
+                cm, n in zip(['hot', 'winter'],  [len(x) for x in series_by_unit])]
+
+        lines = cycle(["-",(0,(3,1))])
+        
+        first_plot = True
+        for ui in xrange(len(uniq_units)):
+            if not first_plot:
+                plot = plot.twinx()
+
+            axis_color = palette[ui][0]
+            plot.set_ylabel(ylabel(uniq_units[ui]), color=axis_color)
+            for l in plot.get_yticklabels():
+                l.set_color(axis_color)
+
+            plot.get_yaxis().get_major_formatter().set_useOffset(False)
+            plot.get_yaxis().set_major_locator(M.ticker.MaxNLocator(nbins=6))
+            
+            plot.set_xlim(trange)
+
+            colours = cycle(palette[ui])
+            line = next(lines)
+            for s, l in series_by_unit[ui]:
+                plot.plot(s.t, s.y, color=next(colours), ls=line, label=l)
+
+            if first_plot:
+                plot.legend(loc=2, fontsize='small')
+                plot.grid()
+            else:
+                plot.legend(loc=1, fontsize='small')
+
+            first_plot = False
+
+    # adapted from http://stackoverflow.com/questions/6963035
+    axis_ymin = min([ax.get_position().ymin for ax in figure.axes])
+    figure.text(0.5, axis_ymin - float(3)/figure.dpi, 'time', ha='center', va='center')
+    if save:
+        figure.savefig(save)
+    else:
+        P.show()
+        
+args = parse_clargs()
+tss = []
+for filename in args.inputs:
+    with open(filename) as f:
+        tss.extend(read_json_timeseries(f))
+
+if args.trange:
+    for ts in tss:
+        ts.tclip(args.trange)
+
+groupby = args.groupby if args.groupby else []
+plots = gather_ts_plots(tss, groupby)
+
+if not args.outfile:
+    M.interactive(False)
+
+plot_plots(plots, save=args.outfile)