diff --git a/benchmarks/imaging_ecal/scripts/aggregator_occupancy.py b/benchmarks/imaging_ecal/scripts/aggregator_occupancy.py
new file mode 100644
index 0000000000000000000000000000000000000000..d10661b93289d91c49abd6a9933d0cb91f30d5b6
--- /dev/null
+++ b/benchmarks/imaging_ecal/scripts/aggregator_occupancy.py
@@ -0,0 +1,151 @@
+'''
+    check occupancy for first-level aggregators
+'''
+import os
+import ROOT
+import pandas as pd
+import numpy as np
+import argparse
+import matplotlib
+import matplotlib.ticker as ticker
+from matplotlib import cm
+from matplotlib import pyplot as plt
+from matplotlib.collections import PatchCollection
+from matplotlib.patches import Rectangle
+
+
+# load root macros, input is an argument string
+def load_root_macros(arg_macros):
+    for path in arg_macros.split(','):
+        path = path.strip()
+        if os.path.exists(path):
+            gROOT.Macro(path)
+            print('Loading root macro: \'{}\' loaded.'.format(path))
+        else:
+            print('Loading root macros: \'{}\' does not exist, skip loading it.'.format(path))
+
+
+# read from RDataFrame and flatten a given collection, return pandas dataframe
+def flatten_collection(rdf, collection, cols=None):
+    if not cols:
+        cols = [str(c) for c in rdf.GetColumnNames() if str(c).startswith('{}.'.format(collection))]
+        cols += [str(c) for c in rdf.GetColumnNames() if str(c).startswith('{}Info.'.format(collection))]
+    else:
+        cols = ['{}.{}'.format(collection, c) for c in cols]
+    if not cols:
+        print('cannot find any branch under collection {}'.format(collection))
+        return pd.DataFrame()
+
+    data = rdf.AsNumpy(cols)
+    # flatten the data, add an event id to identify clusters from different events
+    evns = []
+    for i, vec in enumerate(data[cols[0]]):
+        evns += [i]*vec.size()
+    for n, vals in data.items():
+        # make sure ints are not converted to floats
+        typename = vals[0].__class__.__name__.lower()
+        dtype = np.int64 if 'int' in typename or 'long' in typename else np.float64
+        # type safe creation
+        data[n] = np.asarray([v for vec in vals for v in vec], dtype=dtype)
+    # build data frame
+    dfp = pd.DataFrame({c: pd.Series(v) for c, v in data.items()})
+    dfp.loc[:, 'event'] = evns
+    return dfp
+
+
+def pos2segid(pos, segsize):
+    return np.floor(pos / segsize)
+
+
+def segid2pos(sid, segsize):
+    return (sid + 0.5) * segsize
+
+
+def truncate_colormap(cmap, minval=0.0, maxval=1.0, n=100):
+    new_cmap = matplotlib.colors.LinearSegmentedColormap.from_list(
+        'trunc({n},{a:.2f},{b:.2f})'.format(n=cmap.name, a=minval, b=maxval),
+        cmap(np.linspace(minval, maxval, n)))
+    return new_cmap
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser()
+    parser.add_argument('rec_file', help='Path to reconstruction output file.')
+    parser.add_argument('-o', dest='outdir', default='.', help='Output directory.')
+    parser.add_argument('-m', '--macros', dest='macros', default='rootlogon.C',
+            help='Macro files to be loaded by root, separated by \",\".')
+    parser.add_argument('-c', '--collection', dest='coll', default='RecoEcalBarrelImagingHits',
+            help='Name of the data branch for iamging layers')
+    parser.add_argument('-s', '--segment-size', dest='size', type=float, default=505.0,
+            help='Size of area for one aggregator to cover (mm)')
+    args = parser.parse_args()
+
+    # multi-threading for RDataFrame
+    nthreads = os.cpu_count()//2
+    ROOT.ROOT.EnableImplicitMT(nthreads)
+    print('CPU available {}, using {}'.format(os.cpu_count(), nthreads))
+
+    # declare some functions from c++ script, needed for data frame processing
+    # script_dir = os.path.dirname(os.path.realpath(__file__))
+    # fcode = open(os.path.join(script_dir, 'barrel_clusters.cxx')).read()
+    # ROOT.gInterpreter.Declare(fcode)
+
+    os.makedirs(args.outdir, exist_ok=True)
+    # load macros (add libraries/headers root cannot automatically found here)
+    load_root_macros(args.macros)
+
+    rdf = ROOT.RDataFrame('events', args.rec_file)
+    df = flatten_collection(rdf, args.coll)
+    df.rename(columns={c: c.replace(args.coll + '.', '') for c in df.columns}, inplace=True)
+
+    # half sizes
+    sx, sy = df[['local.x', 'local.y']].max().values
+    print(sx, sy, args.size)
+
+    df.loc[:, 'fpga'] = pos2segid(df['local.y'].values, args.size)
+
+    counts = df[df['sector'] == 8].groupby(['event', 'layer', 'fpga'])['local.z'].count().to_frame(name='nhits')
+    dfa = counts.groupby(['layer', 'fpga']).describe().reset_index()
+    # flatten column levels
+    dfa.columns = ['.'.join(col).strip('.') for col in dfa.columns.values]
+    dfa = dfa.astype({'fpga': int, 'nhits.count': int})
+
+    print(dfa)
+    fig, axs = plt.subplots(3, 2, figsize=(16, 9), dpi=160, sharex='all', sharey='all',
+                            gridspec_kw={'hspace': 0, 'wspace':0.})
+    for ax, il in zip(axs.flat, np.arange(dfa['layer'].max() + 1)):
+        data = dfa.loc[dfa['layer'] == il + 1]
+        ax.errorbar(segid2pos(data['fpga'].values, args.size), data['nhits.max'], xerr=args.size/2., fmt='o', capsize=3)
+        ax.tick_params(labelsize=20)
+        ax.text(0.95, 0.95, 'Layer {}'.format(il + 1), fontsize=24, transform=ax.transAxes, ha='right', va='top')
+        ax.grid(linestyle=':')
+    fig.text(0.05, 0.5, 'Max. Number of Hits / Event / FPGA', fontsize=24, va='center', rotation=90)
+    fig.text(0.5, 0.03, 'z (mm)', fontsize=24, ha='center')
+    fig.savefig(os.path.join(args.outdir, 'occupancy_perlayer.png'))
+
+    fig, ax = plt.subplots(figsize=(16, 9))
+    ax.set_xlim(-sy, sy)
+    ax.set_ylim(0, dfa.layer.max())
+
+    # color map
+    cmap = truncate_colormap(plt.get_cmap('jet'), 0.1, 0.9)
+    dfa.loc[:, 'max_norm'] = dfa['nhits.max'].values / dfa['nhits.max'].max()
+
+    recs, colors = [], []
+    for il in np.arange(dfa['layer'].max() + 1):
+        data = dfa.loc[dfa['layer'] == il + 1]
+        recs += [Rectangle((y - args.size/2., il), args.size, 1) for y in segid2pos(data['fpga'].values, args.size)]
+        # print([((y - args.size, il), args.size*2, 1) for y in segid2pos(data['fpga'].values, args.size)])
+        colors += [cmap(v) for v in data['max_norm'].values]
+    ax.add_collection(PatchCollection(recs, facecolors=colors, edgecolors='k'))
+    ax.tick_params(labelsize=22)
+    ax.set_xlabel('z (mm)', fontsize=24)
+    ax.set_ylabel('Layer', fontsize=24)
+    cb = plt.colorbar(cm.ScalarMappable(norm=matplotlib.colors.Normalize(vmin=0, vmax=dfa['nhits.max'].max()),
+                                        cmap=cmap),
+                      ax=ax, shrink=0.85)
+    cb.ax.tick_params(labelsize=22)
+    cb.ax.get_yaxis().labelpad = 22
+    cb.ax.set_ylabel('Max. Number of Hits / Event', rotation=90, fontsize=24)
+    fig.savefig(os.path.join(args.outdir, 'occupancy_map.png'))
+