diff --git a/doc/BOZ analysis part I parameters determination.ipynb b/doc/BOZ analysis part I parameters determination.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..de0e8d08d9a69a01d5cc890a18a9f1674633594a
--- /dev/null
+++ b/doc/BOZ analysis part I parameters determination.ipynb	
@@ -0,0 +1,557 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import numpy as np\n",
+    "%matplotlib notebook\n",
+    "import matplotlib.pyplot as plt\n",
+    "plt.rcParams['figure.constrained_layout.use'] = True\n",
+    "\n",
+    "import dask\n",
+    "print(f'dask: {dask.__version__}')\n",
+    "import dask.array as da\n",
+    "from dask.distributed import Client, progress\n",
+    "from dask_jobqueue import SLURMCluster\n",
+    "\n",
+    "import netCDF4\n",
+    "import pickle"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from psutil import virtual_memory\n",
+    "import gc\n",
+    "# gc.collect() # run garbage collection to free possible memory\n",
+    "\n",
+    "mem = virtual_memory()\n",
+    "print(f'Physical memory: {mem.total/1024/1024/1024:.0f} Gb')  # total physical memory available"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import logging\n",
+    "logging.basicConfig(filename='example.log', level=logging.DEBUG)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%load_ext autoreload\n",
+    "\n",
+    "%autoreload 2\n",
+    "\n",
+    "import toolbox_scs as tb\n",
+    "print(tb.__file__)\n",
+    "import toolbox_scs.routines.boz as boz"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%load_ext line_profiler\n",
+    "%load_ext memory_profiler"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Slum cluster setup"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Change to *True* to allocate a slurm cluster and run the calculation on it instead of the current machine."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "if False:\n",
+    "    partition = 'exfel'   # For users\n",
+    "    # partition = 'upex'   # For users\n",
+    "\n",
+    "    cluster = SLURMCluster(\n",
+    "        queue=partition,\n",
+    "        local_directory='/scratch',  # Local disk space for workers to use\n",
+    "    \n",
+    "        # Resources per SLURM job (per node, the way SLURM is configured on Maxwell)\n",
+    "        # processes=16 runs 16 Dask workers in a job, so each worker has 1 core & 32 GB RAM.\n",
+    "        processes=8, cores=16, memory='512GB',\n",
+    "        walltime='9:00:00',\n",
+    "        #interface='ib0',\n",
+    "        #job_extra=[\"--reservation=upex_002619\"] # reserved partition\n",
+    "    )\n",
+    "\n",
+    "    # dashboard link\n",
+    "    # print('https://max-jhub.desy.de/user/lleguy/proxy/8787/graph')\n",
+    "    \n",
+    "    # Submit 2 SLURM jobs, for 32 Dask workers\n",
+    "    cluster.scale(32)\n",
+    "    #cluster.adapt(minimum=0, maximum=32)\n",
+    "    \n",
+    "    client = Client(cluster)\n",
+    "    print(\"Created dask client:\", client)\n",
+    "    \n",
+    "    # Get a notbook widget showing the cluster state\n",
+    "    cluster"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "try:\n",
+    "    client.close()\n",
+    "    cluster.close()\n",
+    "except:\n",
+    "    print('No client defined')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Create parameters object"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "params = boz.parameters(proposal=2712, darkrun=5, run=6, module=15)\n",
+    "#params = boz.parameters(proposal=2619, darkrun=33, run=38, module=15)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(params)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### load data persistently"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "params.dask_load()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Dark run inspection"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The aim is to check dark level and extract bad pixel map."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "mean_th = (30, 56)\n",
+    "f = boz.inspect_dark(params.arr_dark, mean_th=mean_th)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "params.mean_th = mean_th\n",
+    "params.set_mask(boz.bad_pixel_map(params))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(params)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Histogram"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "h, f = boz.inspect_histogram(params.proposal, params.run, params.module,\n",
+    "                             mask=params.get_mask() #, extra_lines=True\n",
+    "                         )"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "adding guide to the eye"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ax = f.gca()\n",
+    "pf = np.polyfit([60, 220], [7, 4], 1)\n",
+    "ax.plot([40, 400], 2*10**np.polyval(pf, [40, 400]))\n",
+    "ax.plot([40, 400], 0.25*2*10**np.polyval(pf, [40, 400]))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# ROIs extraction"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "params.rois_th = 50\n",
+    "params.rois = boz.find_rois_from_params(params)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(params)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dark = boz.average_module(params.arr_dark).compute()\n",
+    "data = boz.average_module(params.arr, dark=dark).compute()\n",
+    "dataM = data.mean(axis=0) # mean over pulseId"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_rois(dataM, params.rois, params.rois_th)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Flat field extraction"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The first step is to compute a good average image, this mean remove saturated shots and ignoring bad pixels"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "params.sat_level = 511\n",
+    "res = boz.average_module(params.arr, dark=dark,\n",
+    "                         ret='mean', mask=params.get_mask(), sat_roi=params.rois['sat'],\n",
+    "                         sat_level=params.sat_level)\n",
+    "avg = res.compute().mean(axis=0)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The second step is from that good average image to fit the plane field on n/0 and p/0 rois. We have to make sure that the rois have same width."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_plane_fitting(avg, params.rois)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "fit the plane field correction"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plane = boz.plane_fitting(params)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plane"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "compute the correction and inspect the result of its application"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "params.set_flat_field(plane.x)\n",
+    "ff = boz.compute_flat_field_correction(params.rois, params.get_flat_field())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_plane_fitting(avg/ff, params.rois)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Non-linearities correction extraction"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "N = 40\n",
+    "domain = boz.nl_domain(N, 40, 280)\n",
+    "params.alpha = 0.5\n",
+    "params.max_iter = 25"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## minimizing"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [],
+   "source": [
+    "res, fit_res = boz.nl_fit(params, domain)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "params.set_Fnl(boz.nl_lut(domain, res.x))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_correction(params, gain=3)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f.savefig(f'p{params.proposal}-r{params.run}-d{params.darkrun}-inspect-correction.png', dpi=300)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### plotting the fitted correction"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_Fnl(params.get_Fnl())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f.savefig(f'p{params.proposal}-r{params.run}-d{params.darkrun}-inspect-Fnl.png', dpi=300)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### plotting the fit progresion"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_nl_fit(fit_res)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f.savefig(f'p{params.proposal}-r{params.run}-d{params.darkrun}-inspect-nl-fit.png', dpi=300)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Save the analysis parameters"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "params.save()"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "xfel",
+   "language": "python",
+   "name": "xfel"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.7.3"
+  },
+  "nbsphinx": {
+   "execute": "never"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/doc/BOZ analysis part II run processing.ipynb b/doc/BOZ analysis part II run processing.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..c4a14ab04aa2c899f472534b1283bec1df23a4f8
--- /dev/null
+++ b/doc/BOZ analysis part II run processing.ipynb	
@@ -0,0 +1,467 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import numpy as np\n",
+    "%matplotlib notebook\n",
+    "import matplotlib.pyplot as plt\n",
+    "plt.rcParams['figure.constrained_layout.use'] = True\n",
+    "\n",
+    "import dask\n",
+    "print(f'dask: {dask.__version__}')\n",
+    "import dask.array as da\n",
+    "from dask.distributed import Client, progress\n",
+    "from dask_jobqueue import SLURMCluster\n",
+    "\n",
+    "import netCDF4\n",
+    "import xarray as xr"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from psutil import virtual_memory\n",
+    "import gc\n",
+    "# gc.collect() # run garbage collection to free possible memory\n",
+    "\n",
+    "mem = virtual_memory()\n",
+    "print(f'Physical memory: {mem.total/1024/1024/1024:.0f} Gb')  # total physical memory available"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import logging\n",
+    "logging.basicConfig(filename='example.log', level=logging.DEBUG)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%load_ext autoreload\n",
+    "\n",
+    "%autoreload 2\n",
+    "\n",
+    "import toolbox_scs as tb\n",
+    "print(tb.__file__)\n",
+    "import toolbox_scs.routines.boz as boz"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%load_ext line_profiler\n",
+    "%load_ext memory_profiler"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Slum cluster setup"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Change to *True* to allocate a slurm cluster and run the calculation on it instead of the current machine."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "if False:\n",
+    "    partition = 'exfel'   # For users\n",
+    "    # partition = 'upex'   # For users\n",
+    "\n",
+    "    cluster = SLURMCluster(\n",
+    "        queue=partition,\n",
+    "        local_directory='/scratch',  # Local disk space for workers to use\n",
+    "    \n",
+    "        # Resources per SLURM job (per node, the way SLURM is configured on Maxwell)\n",
+    "        # processes=16 runs 16 Dask workers in a job, so each worker has 1 core & 32 GB RAM.\n",
+    "        processes=8, cores=16, memory='512GB',\n",
+    "        walltime='24:00:00',\n",
+    "        #interface='ib0',\n",
+    "        #job_extra=[\"--reservation=upex_002619\"] # reserved partition\n",
+    "    )\n",
+    "\n",
+    "    # dashboard link\n",
+    "    # print('https://max-jhub.desy.de/user/lleguy/proxy/8787/graph')\n",
+    "    \n",
+    "    # Submit 2 SLURM jobs, for 32 Dask workers\n",
+    "    cluster.scale(32)\n",
+    "    #cluster.adapt(minimum=0, maximum=32)\n",
+    "    \n",
+    "    client = Client(cluster)\n",
+    "    print(\"Created dask client:\", client)\n",
+    "    \n",
+    "    # Get a notbook widget showing the cluster state\n",
+    "    cluster"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "try:\n",
+    "    client.close()\n",
+    "    cluster.close()\n",
+    "except:\n",
+    "    print('No client defined')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Loading analysis parameters"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "params = boz.parameters.load('./parameters_p2712_d5_r6.json')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [],
+   "source": [
+    "print(params)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Dark run inspection"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The aim is to check dark level and extract bad pixel map."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "proposalNB = 2712\n",
+    "darkrunNB = 10\n",
+    "moduleNB = 15\n",
+    "\n",
+    "arr_dark, tid_dark = boz.load_dssc_module(proposalNB, darkrunNB, moduleNB)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_dark(arr_dark)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Check ROIs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Let's check the ROIs used in the part I on a run later"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "runNB = 13"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dark = boz.average_module(arr_dark).compute()\n",
+    "arr, tid = boz.load_dssc_module(proposalNB, runNB, moduleNB)\n",
+    "data = boz.average_module(arr, dark=dark).compute()\n",
+    "dataM = data.mean(axis=0) # mean over pulseId"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_rois(dataM, params.rois, params.rois_th)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "it's a bit off, also we can see from the blur that the photon energy was varied in this run."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "rois_th = 35\n",
+    "rois = boz.find_rois(dataM, rois_th)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_rois(dataM, rois, rois_th)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We got new rois."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Compute flat field with new rois"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We use the previously fitted plane on the new roi."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ff = boz.compute_flat_field_correction(rois, params.get_flat_field())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f = boz.inspect_plane_fitting(dataM/ff, rois)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "some issue in the plotting since the rois have different size"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Process a run"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# load data\n",
+    "arr, tid = boz.load_dssc_module(proposalNB, runNB, moduleNB)\n",
+    "arr_dark, tid_dark = boz.load_dssc_module(proposalNB, darkrunNB, moduleNB)\n",
+    "\n",
+    "# make sure to rechunk the arrays\n",
+    "arr = arr.rechunk((100,-1,-1,-1))\n",
+    "arr_dark = arr_dark.rechunk((100,-1,-1,-1))\n",
+    "\n",
+    "#no correction\n",
+    "data = boz.process(np.arange(2**9), arr_dark, arr, tid, rois, params.get_mask(),\n",
+    "                   np.ones_like(ff), params.sat_level)\n",
+    "#with flat field correction\n",
+    "data_ff = boz.process(np.arange(2**9), arr_dark, arr, tid, rois, params.get_mask(),\n",
+    "                      ff, params.sat_level)\n",
+    "# with flat field and non linear correction\n",
+    "data_ff_nl = boz.process(params.get_Fnl(), arr_dark, arr, tid, rois, params.get_mask(),\n",
+    "                         ff, params.sat_level)\n",
+    "\n",
+    "# drop saturated shots\n",
+    "d = data.where(data['sat_sat'] == False, drop=True)\n",
+    "d_ff = data_ff.where(data_ff['sat_sat'] == False, drop=True)\n",
+    "d_ff_nl = data_ff_nl.where(data_ff_nl['sat_sat'] == False, drop=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Load rest of data"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "run, data = tb.load(proposalNB, runNB, ['nrj'])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "r = xr.merge([data, d], join='inner')\n",
+    "r_ff = xr.merge([data, d_ff], join='inner')\n",
+    "r_ff_nl = xr.merge([data, d_ff_nl], join='inner')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "scrolled": false
+   },
+   "outputs": [],
+   "source": [
+    "res = tb.xas(r_ff_nl, 0.1, Iokey = '0', Itkey = 'n', nrjkey = 'nrj', plot=True)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Comparison spectra between the different correction"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "res = tb.xas(r, 0.1, Iokey = '0', Itkey = 'n', nrjkey = 'nrj', plot=False)\n",
+    "res_ff = tb.xas(r_ff, 0.1, Iokey = '0', Itkey = 'n', nrjkey = 'nrj', plot=False)\n",
+    "res_ff_nl = tb.xas(r_ff_nl, 0.1, Iokey = '0', Itkey = 'n', nrjkey = 'nrj', plot=False)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "f = plt.figure()\n",
+    "ax = f.gca()\n",
+    "ax.errorbar(res['nrj'], res['muA'] - res['muA'][0], res['sterrA'], label='raw')\n",
+    "ax.errorbar(res_ff['nrj'], res_ff['muA'] - res_ff['muA'][0], res_ff['sterrA'], label='ff')\n",
+    "ax.errorbar(res_ff_nl['nrj'], res_ff_nl['muA'] - res_ff_nl['muA'][0], res_ff_nl['sterrA'], label='ff+nl')\n",
+    "ax.set_xlabel('Energy (eV)')\n",
+    "ax.set_ylabel('XAS')\n",
+    "ax.set_xlim([695, 740])\n",
+    "ax.legend(loc=2)\n",
+    "\n",
+    "from mpl_toolkits.axes_grid1.inset_locator import inset_axes, mark_inset\n",
+    "\n",
+    "axins = inset_axes(ax, width='40%', height='30%', loc=1)\n",
+    "axins.errorbar(res['nrj'], res['muA'] - res['muA'][0], res['sterrA'], label='raw')\n",
+    "axins.errorbar(res_ff['nrj'], res_ff['muA'] - res_ff['muA'][0], res_ff['sterrA'], label='ff')\n",
+    "axins.errorbar(res_ff_nl['nrj'], res_ff_nl['muA'] - res_ff_nl['muA'][0], res_ff_nl['sterrA'], label='ff+nl')\n",
+    "axins.set_xlim([695, 698])\n",
+    "axins.set_ylim([-0.005, 0.005])\n",
+    "mark_inset(ax, axins, loc1=2, loc2=4, fc=\"none\", ec=\"0.5\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "xfel",
+   "language": "python",
+   "name": "xfel"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.7.3"
+  },
+  "nbsphinx": {
+   "execute": "never"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/doc/howtos.rst b/doc/howtos.rst
index 7d3ae1ded8e4ec171c2bcf3a02b23d759786e8eb..5175138688de88b59af59edd992a8c57faf08254 100644
--- a/doc/howtos.rst
+++ b/doc/howtos.rst
@@ -38,4 +38,6 @@ Photo-Electron Spectrometer (PES)
 routines
 --------
 
+* :doc:`BOZ analysis part I parameters determination <BOZ analysis part I parameters determination>`.
+* :doc:`BOZ analysis part II run processing <BOZ analysis part II run processing>`.
 * *to do*
diff --git a/src/toolbox_scs/routines/XAS.py b/src/toolbox_scs/routines/XAS.py
index 3bf52a58037535ca166795abf956d5e514a20de8..e1e564dbc8413cdcd03aec4a50faad36e7021457 100644
--- a/src/toolbox_scs/routines/XAS.py
+++ b/src/toolbox_scs/routines/XAS.py
@@ -138,7 +138,7 @@ def xas(nrun, bins=None, Iokey='SCS_SA3', Itkey='MCP3peaks', nrjkey='nrj',
                 desired bins or a float for the desired bin width.
             Iokey: string for the Io fields, typically 'SCS_XGM'
             Itkey: string for the It fields, typically 'MCP3apd'
-            NRJkey: string for the nrj fields, typically 'nrj'
+            nrjkey: string for the nrj fields, typically 'nrj'
             Iooffset: offset to apply on Io
             plot: boolean, displays a XAS spectrum if True
             fluorescence: boolean, if True, absorption is the ratio,
@@ -154,7 +154,10 @@ def xas(nrun, bins=None, Iokey='SCS_SA3', Itkey='MCP3peaks', nrjkey='nrj',
                 counts: the number of events in each bin
     """
 
-    stacked = nrun.stack(dummy_=['trainId', 'sa3_pId'])
+    if 'pulseId' in nrun:
+        stacked = nrun.stack(dummy_=['trainId', 'pulseId'])
+    else:
+        stacked = nrun.stack(dummy_=['trainId', 'sa3_pId'])
 
     Io = stacked[Iokey].values + Iooffset
     It = stacked[Itkey].values
@@ -221,7 +224,8 @@ def xas(nrun, bins=None, Iokey='SCS_SA3', Itkey='MCP3peaks', nrjkey='nrj',
             runNB = int(re.findall(r'r(\d{4})', nrun.attrs['runFolder'])[0])
             ax1.set_title(f'run {runNB} p{proposalNB}')
         except:
-            f.suptitle(nrun.attrs['plot_title'])
+            if 'plot_title' in nrun.attrs:
+                f.suptitle(nrun.attrs['plot_title'])
 
         ax2 = plt.subplot(gs[1])
         ax2.bar(bins_c, nosample['counts'], width=0.80*(bins_c[1]-bins_c[0]),
diff --git a/src/toolbox_scs/routines/__init__.py b/src/toolbox_scs/routines/__init__.py
index d8fbb2647fd36ecd3b9381dd554fabed3f3cd180..1452cf1e2531fb13dd4b6658dc04f01c000c3299 100644
--- a/src/toolbox_scs/routines/__init__.py
+++ b/src/toolbox_scs/routines/__init__.py
@@ -1,4 +1,5 @@
 from .XAS import *
+from .boz import *
 
 # Module name is the same as a child function, we use alias to avoid conflict
 import toolbox_scs.routines.knife_edge as knife_edge_module
@@ -7,4 +8,5 @@ from .knife_edge import *
 __all__ = (
     knife_edge_module.__all__
     + XAS.__all__
+    + boz.__all__
 )
diff --git a/src/toolbox_scs/routines/boz.py b/src/toolbox_scs/routines/boz.py
new file mode 100644
index 0000000000000000000000000000000000000000..3cd9472f7b6ce7b5c09b28c047367f4c14d9d4a8
--- /dev/null
+++ b/src/toolbox_scs/routines/boz.py
@@ -0,0 +1,1357 @@
+"""
+Beam splitting Off-axis Zone plate analysis routines.
+
+Copyright (2021) SCS Team.
+"""
+
+import time
+import datetime
+import json
+
+import numpy as np
+import xarray as xr
+import dask.array as da
+from scipy.optimize import minimize
+
+import matplotlib.pyplot as plt
+
+from extra_data import open_run
+
+__all__ = [
+    'load_dssc_module',
+    'inspect_dark',
+    'average_module',
+    'plane_fitting',
+    'compute_flat_field_correction',
+    'nl_crit',
+    'inspect_correction',
+    'find_rois',
+    'nl_domain'
+]
+
+
+class parameters():
+    """Parameters contains all input parameters for the BOZ corrections.
+
+    This is used in beam splitting off-axis zone plate spectrocopy analysis as
+    well as the during the determination of correction parameters themselves to
+    ensure they can be reproduced.
+
+    Inputs
+    ------
+    proposal: int, proposal number
+    darkrun: int, run number for the dark run
+    run: int, run number for the data run
+    module: int: DSSC module number
+    """
+    def __init__(self, proposal, darkrun, run, module):
+        self.proposal = proposal
+        self.darkrun = darkrun
+        self.run = run
+        self.module = module
+        self.mask_idx = None
+        self.mean_th = (None, None)
+        self.std_th = (None, None)
+        self.rois = None
+        self.rois_th = None
+        self.flat_field = None
+        self.Fnl = None
+        self.alpha = None
+        self.sat_level = None
+        self.max_iter = None
+
+        # temporary data
+        self.arr_dark = None
+        self.tid_dark = None
+        self.arr = None
+        self.tid = None
+
+    def dask_load(self):
+        """Load dask data array in memory."""
+        self.arr_dark, self.tid_dark = load_dssc_module(self.proposal,
+            self.darkrun, self.module, drop_intra_darks=True, persist=True)
+        self.arr, self.tid = load_dssc_module(self.proposal, self.run,
+            self.module, drop_intra_darks=True, persist=True)
+
+        # make sure to rechunk the arrays
+        self.arr = self.arr.rechunk((100, -1, -1, -1))
+        self.arr_dark = self.arr_dark.rechunk((100, -1, -1, -1))
+
+    def set_mask(self, arr):
+        """Set mask of bad pixels.
+
+        Inputs
+        ------
+        arr: either a boolean array of a DSSC module image or a list of bad
+            pixel indices
+        """
+        if type(arr) is not list:
+            self.mask_idx = np.argwhere(arr == False).tolist()
+            self.mask = arr
+        else:
+            self.mask_idx = arr
+            mask = np.ones((128, 512), dtype=bool)
+            for k in self.mask_idx:
+                mask[k[0], k[1]] = False
+            self.mask = mask
+
+    def get_mask(self):
+        """Get the boolean array bad pixel of a DSSC module."""
+        return self.mask
+
+    def get_mask_idx(self):
+        """Get the list of bad pixel indices."""
+        return self.mask_idx
+
+    def set_flat_field(self, plane):
+        """Set the flat field plane definition."""
+        if type(plane) is not list:
+            self.flat_field = plane.tolist()
+        else:
+            self.flat_field = plane
+
+    def get_flat_field(self):
+        """Get the flat field plane definition."""
+        return np.array(self.flat_field)
+
+    def set_Fnl(self, Fnl):
+        """Set the non-linear correction function."""
+        if isinstance(Fnl, list):
+            self.Fnl = Fnl
+        else:
+            self.Fnl = Fnl.tolist()
+
+    def get_Fnl(self):
+        """Get the non-linear correction function."""
+        return np.array(self.Fnl)
+
+    def save(self, path='./'):
+        """Save the parameters as a JSON file.
+
+        Inputs
+        ------
+        path: str, where to save the file, default to './'
+        """
+        v = {}
+        v['proposal'] = self.proposal
+        v['darkrun'] = self.darkrun
+        v['run'] = self.run
+        v['module'] = self.module
+
+        v['mask'] = self.mask_idx
+        v['mean_th'] = self.mean_th
+        v['std_th'] = self.std_th
+
+        v['rois'] = self.rois
+        v['rois_th'] = self.rois_th
+
+        v['flat_field'] = self.flat_field
+
+        v['Fnl'] = self.Fnl
+        v['alpha'] = self.alpha
+        v['sat_level'] = self.sat_level
+        v['max_iter'] = self.max_iter
+
+        fname = f'parameters_p{self.proposal}_d{self.darkrun}_r{self.run}.json'
+
+        with open(path + fname, 'w') as f:
+            json.dump(v, f)
+            print(path + fname)
+
+    @classmethod
+    def load(cls, fname):
+        """Load parameters from a JSON file.
+
+        Inputs
+        ------
+        fname: string, name a the JSON file to load
+        """
+        with open(fname, 'r') as f:
+            v = json.load(f)
+        c = cls(v['proposal'], v['darkrun'], v['run'], v['module'])
+
+        c.mean_th = v['mean_th']
+        c.std_th = v['std_th']
+        c.set_mask(v['mask'])
+
+        c.rois = v['rois']
+        c.rois_th = v['rois_th']
+
+        c.set_flat_field(v['flat_field'])
+
+        c.set_Fnl(v['Fnl'])
+        c.alpha = v['alpha']
+        c.sat_level = v['sat_level']
+        c.max_iter = v['max_iter']
+
+        return c
+
+    def __str__(self):
+        f = f'proposal:{self.proposal} darkrun:{self.darkrun} run:{self.run}'
+        f += f' module:{self.module}\n'
+
+        if self.mask_idx is not None:
+            f += f'mean threshold:{self.mean_th} std threshold:{self.std_th}\n'
+            f += f'mask:(#{len(self.mask_idx)}) {self.mask_idx}\n'
+        else:
+            f += 'mask:None\n'
+
+        f += f'rois threshold: {self.rois_th}\n'
+        f += f'rois: {self.rois}\n'
+
+        f += f'flat field: {self.flat_field}\n'
+
+        if self.Fnl is not None:
+            f += f'dFnl: {np.array(self.Fnl) - np.arange(2**9)}\n'
+            f += f'alpha:{self.alpha}, sat. level:{self.sat_level}, '
+            f += f' max. iter.:{self.max_iter}'
+        else:
+            f += 'Fnl: None'
+
+        return f
+
+
+def _plane_flat_field(p, roi):
+    """Compute the p plane over the given roi.
+
+    Given the plane parameters p, compute the plane over the roi
+    size.
+
+    Parameters
+    ----------
+    p: a vector of a, b, c, d plane parameter with the
+       plane given by ax+ by + cz + d = 0
+    roi: a dictionnary roi['yh', 'yl', 'xh', 'xl']
+
+    Returns
+    -------
+    the plane field given by p evaluated on the roi
+    extend.
+
+    TODO
+    ----
+    the hexagonal lattice is currently ignored.
+    """
+    a, b, c, d = p
+
+    nY, nX = roi['yh'] - roi['yl'], roi['xh'] - roi['xl']
+
+    X = np.arange(nX)/100
+    Y = np.arange(nY)[:, np.newaxis]/100
+
+    Z = -(a*X + b*Y + d)/c
+
+    return Z
+
+
+def compute_flat_field_correction(rois, p, plot=False):
+    """Compute the plane field correction on beam rois.
+
+    Inputs
+    ------
+    rois: dictionnary of beam rois['n', '0', 'p']
+    p: plane vector
+    plot: boolean, True by default, diagnostic plot
+
+    Returns
+    -------
+    numpy 2D array of the flat field correction evaluated over one DSSC ladder
+    (2 sensors)
+    """
+    flat_field = np.ones((128, 512))
+
+    r = 'n'
+    flat_field[rois[r]['yl']:rois[r]['yh'], rois[r]['xl']:rois[r]['xh']] = \
+                       _plane_flat_field(p, rois[r])
+    r = 'p'
+    flat_field[rois[r]['yl']:rois[r]['yh'], rois[r]['xl']:rois[r]['xh']] = \
+                       np.fliplr(_plane_flat_field(p, rois[r]))
+
+    if plot:
+        f, ax = plt.subplots(1, 1, figsize=(6, 2))
+        img = ax.pcolormesh(np.flipud(flat_field[:, :256]), cmap='Greys_r')
+        f.colorbar(img, ax=[ax], label='amplitude')
+        ax.set_xlabel('px')
+        ax.set_ylabel('px')
+        ax.set_aspect('equal')
+
+    return flat_field
+
+
+def nl_domain(N, low, high):
+    """Create the input domain where the non-linear correction defined.
+
+    Inputs
+    ------
+    N: integer, number of control points or intervals
+    low: input values below or equal to low will not be corrected
+    high: input values higher or equal to high will not be corrected
+
+    Returns
+    -------
+    array of 2**9 integer values with N segments
+    """
+    x = np.arange(2**9)
+    vx = x.copy()
+    eps = 1e-5
+    vx[(x > low)*(x < high)] = np.linspace(1, N+1-eps, high-low-1)
+    vx[x <= low] = 0
+    vx[x >= high] = 0
+
+    return vx
+
+
+def nl_lut(domain, dy):
+    """Compute the non-linear correction.
+
+    Inputs
+    ------
+    domain: input domain where dy is defined. For zero no correction is
+        defined. For non-zero value x, dy[x] is applied.
+    dy: a vector of deviation from linearity on control point homogeneously
+        dispersed over 9 bits.
+
+    Returns
+    -------
+    F_INL: default None, non linear correction function given as a
+           lookup table with 9 bits integer input
+    """
+    x = np.arange(2**9)
+    ndy = np.insert(dy, 0, 0)  # add zero to dy
+
+    f = x + ndy[domain]
+
+    return f
+
+
+def find_rois(data_mean, threshold):
+    """Find rois from 3 beams configuration.
+
+    Inputs
+    ------
+    data_mean: dark corrected average image
+    threshold: threshold value to find beams
+
+    Returns
+    -------
+    rois: dictionnary of rois
+    """
+    # compute vertical and horizontal projection
+    pX = data_mean.sum(axis=0)
+    pX = pX[:256]  # half the ladder since there is a gap in the middle
+    pY = data_mean.sum(axis=1)
+
+    # along X
+    lowX = int(np.argmax(pX[:128] > threshold))  # 1st occurrence returned
+    highX = int(np.argmax(pX[128:] < threshold) + 128)  # 1st occ. returned
+    midX = (lowX + highX)//2
+    leftX = int(np.argmin(pX[(lowX+20):midX]) + lowX + 20)
+    rightX = int(np.argmin(pX[midX:highX-20]) + midX)
+
+    # along Y
+    lowY = int(np.argmax(pY[:64] > threshold))  # 1st occurrence returned
+    highY = int(np.argmax(pY[64:] < threshold) + 64)  # 1st occ. returned
+
+    # define rois
+    rois = {}
+    # baseline correction rois
+    for k in [0, 1, 2, 3]:
+        rois[f'b{k}'] = {'xl': k*64, 'xh': (k+1)*64, 'yl': 0, 'yh': lowY}
+    for k in [8, 9, 10, 11]:
+        rois[f'b{k}'] = {'xl': (k-8)*64, 'xh': (k+1-8)*64,
+                         'yl': highY, 'yh': 128}
+
+    # beam roi
+    rois['n'] = {'xl': lowX, 'xh': leftX, 'yl': lowY, 'yh': highY}
+    rois['0'] = {'xl': leftX, 'xh': rightX, 'yl': lowY, 'yh': highY}
+    rois['p'] = {'xl': rightX, 'xh': highX, 'yl': lowY, 'yh': highY}
+
+    # saturation roi
+    rois['sat'] = {'xl': lowX, 'xh': highX, 'yl': lowY, 'yh': highY}
+
+    # ASICs splitted beam roi
+    rois['0X'] = {'xl': lowX, 'xh': 1*64, 'yl': lowY, 'yh': 64}
+    rois['1X1'] = {'xl': 64, 'xh': leftX, 'yl': lowY, 'yh': 64}
+
+    rois['1X2'] = {'xl': leftX, 'xh': 2*64, 'yl': lowY, 'yh': 64}
+    rois['2X1'] = {'xl': 2*64, 'xh': rightX, 'yl': lowY, 'yh': 64}
+
+    rois['2X2'] = {'xl': rightX, 'xh': 3*64, 'yl': lowY, 'yh': 64}
+    rois['3X'] = {'xl': 3*64, 'xh': highX, 'yl': lowY, 'yh': 64}
+
+    rois['8X'] = {'xl': lowX, 'xh': 1*64, 'yl': 64, 'yh': highY}
+    rois['9X1'] = {'xl': 64, 'xh': leftX, 'yl': 64, 'yh': highY}
+
+    rois['9X2'] = {'xl': leftX, 'xh': 2*64, 'yl': 64, 'yh': highY}
+    rois['10X1'] = {'xl': 2*64, 'xh': rightX, 'yl': 64, 'yh': highY}
+
+    rois['10X2'] = {'xl': rightX, 'xh': 3*64, 'yl': 64, 'yh': highY}
+    rois['11X'] = {'xl': 3*64, 'xh': highX, 'yl': 64, 'yh': highY}
+
+    return rois
+
+
+def find_rois_from_params(params):
+    """Find rois from 3 beams configuration.
+
+    Inputs
+    ------
+    params: parameters
+
+    Returns
+    -------
+    rois: dictionnary of rois
+    """
+    assert params.arr_dark is not None, "Data not loaded"
+    dark = average_module(params.arr_dark).compute()
+
+    assert params.arr is not None, "Data not loaded"
+    data = average_module(params.arr, dark=dark).compute()
+    data_mean = data.mean(axis=0)  # mean over pulseId
+    threshold = params.rois_th
+
+    return find_rois(data_mean, threshold)
+
+
+def inspect_rois(data_mean, rois, threshold=None, allrois=False):
+    """Find rois from 3 beams configuration from mean module image.
+
+    Inputs
+    ------
+    data_mean: mean module image
+    threshold: float, default None, threshold value used to detect beams
+        boundaries
+    allrois: boolean, default False, plot all rois defined in rois or only the
+        main ones (['n', '0', 'p'])
+
+    Returns
+    -------
+    matplotlib figure
+    """
+    # compute vertical and horizontal projection
+    pX = data_mean.sum(axis=0)
+    pX = pX[:256]  # half the ladder since there is a gap in the middle
+    pY = data_mean.sum(axis=1)
+
+    # Set up the axes with gridspec
+    fig = plt.figure(figsize=(5, 3))
+    grid = plt.GridSpec(2, 2,  width_ratios=(1, 4), height_ratios=(2, 1),
+                        # left=0.1, right=0.9, bottom=0.1, top=0.9,
+                        wspace=0.05, hspace=0.05)
+    main_ax = fig.add_subplot(grid[0, 1])
+    y = fig.add_subplot(grid[0, 0], xticklabels=[], sharey=main_ax)
+    x = fig.add_subplot(grid[1, 1], yticklabels=[], sharex=main_ax)
+
+    # scatter points on the main axes
+    Xs = np.arange(len(pX))
+    Ys = np.arange(len(pY))
+    main_ax.pcolormesh(Xs, Ys, np.flipud(data_mean[:, :256]),
+                       cmap='Greys_r',
+                       vmin=0,
+                       vmax=np.percentile(data_mean[:, :256], 99))
+    main_ax.set_aspect('equal')
+
+    x.plot(Xs, pX)
+    x.invert_yaxis()
+    if threshold is not None:
+        x.axhline(threshold, c='k', alpha=.5)
+    x.axvline(rois['n']['xl'], c='r', alpha=.5)
+    x.axvline(rois['0']['xl'], c='r', alpha=.6)
+
+    x.axvline(rois['p']['xl'], c='r', alpha=.7)
+    x.axvline(rois['p']['xh'], c='r', alpha=.8)
+
+    y.plot(pY, np.arange(len(pY)-1, -1, -1))
+    y.invert_xaxis()
+    if threshold is not None:
+        y.axvline(threshold, c='k', alpha=.5)
+    y.axhline(127-rois['p']['yl'], c='r', alpha=.5)
+    y.axhline(127-rois['p']['yh'], c='r', alpha=.6)
+
+    return fig
+
+
+def histogram_module(proposalNB, runNB, moduleNB, mask=None):
+    """Compute a histogram of the 9 bits raw pixel values over a module.
+
+    Inputs
+    ------
+    proposalNB: proposal number
+    runNB: run number
+    moduleNB: module number
+    mask: optional bad pixel mask
+
+    Returns
+    -------
+    histogram
+    """
+    run = open_run(proposal=proposalNB, run=runNB)
+
+    # DSSC
+    source = f'SCS_DET_DSSC1M-1/DET/{moduleNB}CH0:xtdf'
+    key = 'image.data'
+
+    arr = run[source, key].dask_array()
+    arr = arr.rechunk((100, -1, -1, -1))
+
+    if mask is not None:
+        w = da.repeat(da.repeat(da.array(mask[None, None, :, :]),
+                arr.shape[1], axis=1), arr.shape[0], axis=0)
+        w = w.rechunk((100, -1, -1, -1))
+        return da.bincount(arr.ravel(), w.ravel()).compute()
+    else:
+        return da.bincount(arr.ravel()).compute()
+
+
+def inspect_histogram(proposalNB, runNB, moduleNB,
+        mask=None, extra_lines=False):
+    """Compute and plot a histogram of the 9 bits raw pixel values.
+
+    Inputs
+    ------
+    proposalNB: proposal number
+    runNB: run number
+    moduleNB: module number
+    mask: optional bad pixel mask
+    extra_lines: boolean, default False, plot extra lines at period values
+
+    Returns
+    -------
+    histogram
+    figure
+    """
+    h = histogram_module(proposalNB, runNB, moduleNB, mask=mask)
+    from matplotlib.ticker import MultipleLocator
+
+    f = plt.figure(figsize=(6, 3))
+    plt.plot(np.arange(2**9), h, marker='o',
+            ms=3, markerfacecolor='none', lw=1)
+
+    if extra_lines:
+        for k in range(50, 271):
+            if not (k - 2) % 8:
+                plt.axvline(k, c='k', alpha=0.5, ls='--')
+            if not (k - 3) % 16:
+                plt.axvline(k, c='g', alpha=0.3, ls='--')
+            if not (k - 7) % 32:
+                plt.axvline(k, c='r', alpha=0.3, ls='--')
+
+    plt.axvline(271, c='C1', alpha=0.5, ls='--')
+    plt.fill_between(np.arange(2**9)[30:51], h[30:51], 1, alpha=0.5)
+
+    plt.ylim([1, None])
+    plt.xlim([0, 2**9-1])
+    plt.yscale('log')
+    plt.axes().xaxis.set_minor_locator(MultipleLocator(10))
+    plt.xlabel('DSSC pixel value')
+    plt.ylabel('counts')
+    plt.title(f'p{proposalNB} run {runNB}')
+
+    return h, f
+
+
+def load_dssc_module(proposalNB, runNB, moduleNB=15,
+                     subset=slice(None), drop_intra_darks=True, persist=True):
+    """Load single module dssc data as dask array.
+
+    Inputs
+    ------
+    proposalNB: proposal number
+    runNB: run number
+    moduleNB: default 15, module number
+    subset: default slice(None), subset of trains to load
+    drop_intra_darks: boolean, default True, remove intra darks from the data
+    persist: load all data in memory
+
+    Returns
+    -------
+    arr: dask array of reshaped dssc data (trainId, pulseId, x, y)
+    tid: array of train id number
+    """
+    run = open_run(proposal=proposalNB, run=runNB)
+
+    # DSSC
+    source = f'SCS_DET_DSSC1M-1/DET/{moduleNB}CH0:xtdf'
+    key = 'image.data'
+
+    arr = run[source, key][subset].dask_array()
+    # fix 256 value becoming spuriously 0 instead
+    arr[arr == 0] = 256
+
+    ppt = run[source, key][subset].data_counts()
+    tid = ppt.index.to_numpy()
+
+    ppt = np.unique(ppt)
+    assert ppt.shape[0] == 1, "number of pulses changed during the run"
+    ppt = ppt[0]
+
+    # reshape in trainId, pulseId, 2d-image
+    arr = arr.reshape(-1, ppt, arr.shape[2], arr.shape[3])
+
+    # drop intra darks
+    if drop_intra_darks:
+        arr = arr[:, ::2, :, :]
+
+    # load data in memory
+    if persist:
+        arr = arr.persist()
+
+    return arr, tid
+
+
+def average_module(arr, dark=None, ret='mean',
+                   mask=None, sat_roi=None, sat_level=300, F_INL=None):
+    """Compute the average or std over a module.
+
+    Inputs
+    ------
+    arr: dask array of reshaped dssc data (trainId, pulseId, x, y)
+    dark: default None, dark to be substracted
+    ret: string, either 'mean' to compute the mean or 'std' to compute the
+         standard deviation
+    mask: default None, mask of bad pixels to ignore
+    sat_roi: roi over which to check for pixel with values larger than
+             sat_level to drop the image from the average or std
+    sat_level: int, minimum pixel value for a pixel to be considered saturated
+    F_INL: default None, non linear correction function given as a
+           lookup table with 9 bits integer input
+
+    Returns
+    -------
+    average or standard deviation image
+    """
+    # F_INL
+    if F_INL is not None:
+        narr = arr.map_blocks(lambda x: F_INL[x])
+    else:
+        narr = arr
+
+    if mask is not None:
+        narr = narr*mask
+
+    if sat_roi is not None:
+        temp = (da.logical_not(da.any(
+                narr[:, :, sat_roi['yl']:sat_roi['yh'],
+                           sat_roi['xl']:sat_roi['xh']] >= sat_level,
+                               axis=[2, 3], keepdims=True)))
+        not_sat = da.repeat(da.repeat(temp, 128, axis=2), 512, axis=3)
+
+    if dark is not None:
+        narr = narr - dark
+
+    if ret == 'mean':
+        if sat_roi is not None:
+            return da.average(narr, axis=0, weights=not_sat)
+        else:
+            return narr.mean(axis=0)
+    elif ret == 'std':
+        return narr.std(axis=0)
+    else:
+        raise ValueError(f'ret={ret} not supported')
+
+
+def _add_colorbar(im, ax, loc='right', size='5%', pad=0.05):
+    """Add a colobar on a new axes so it match the plot size.
+
+    Inputs
+    ------
+    im: image plotted
+    ax: axes on which the image was plotted
+    loc: string, default 'right', location of the colorbar
+    size: string, default '5%', proportion of the colobar with respect to the
+          plotted image
+    pad: float, default 0.05, pad width between plot and colorbar
+    """
+    from mpl_toolkits.axes_grid1 import make_axes_locatable
+
+    fig = ax.figure
+    divider = make_axes_locatable(ax)
+    cax = divider.append_axes(loc, size=size, pad=pad)
+    cbar = fig.colorbar(im, cax=cax)
+
+    return cbar
+
+
+def bad_pixel_map(params):
+    """Compute the bad pixels map.
+
+    Inputs
+    ------
+    params: parameters
+
+    Returns
+    -------
+    bad pixel map
+    """
+    assert params.arr_dark is not None, "Data not loaded"
+
+    # compute mean and std
+    dark_mean = params.arr_dark.mean(axis=(0, 1)).compute()
+    dark_std = params.arr_dark.std(axis=(0, 1)).compute()
+
+    mask = np.ones_like(dark_mean)
+    if params.mean_th[0] is not None:
+        mask *= dark_mean >= params.mean_th[0]
+    if params.mean_th[1] is not None:
+        mask *= dark_mean <= params.mean_th[1]
+    if params.std_th[0] is not None:
+        mask *= dark_std >= params.std_th[0]
+    if params.std_th[1] is not None:
+        mask *= dark_std >= params.std_th[1]
+
+    print(f'# bad pixel: {int(128*512-mask.sum())}')
+
+    return mask.astype(bool)
+
+
+def inspect_dark(arr, mean_th=(None, None), std_th=(None, None)):
+    """Inspect dark run data and plot diagnostic.
+
+    Inputs
+    ------
+    arr: dask array of reshaped dssc data (trainId, pulseId, x, y)
+    mean_th: tuple of threshold (low, high), default (None, None), to compute
+            a mask of good pixels for which the mean dark value lie inside this
+            range
+    std_th: tuple of threshold (low, high), default (None, None), to compute a
+            mask of bad pixels for which the dark std value lie inside this
+            range
+
+    Returns
+    -------
+    fig: matplotlib figure
+    """
+    # compute mean and std
+    dark_mean = arr.mean(axis=(0, 1)).compute()
+    dark_std = arr.std(axis=(0, 1)).compute()
+
+    fig = plt.figure(figsize=(7, 2.7))
+    gs = fig.add_gridspec(2, 4)
+    ax1 = fig.add_subplot(gs[0, 1:])
+    ax1.set_xticklabels([])
+    ax1.set_yticklabels([])
+    ax11 = fig.add_subplot(gs[0, 0])
+
+    ax2 = fig.add_subplot(gs[1, 1:])
+    ax2.set_xticklabels([])
+    ax2.set_yticklabels([])
+    ax22 = fig.add_subplot(gs[1, 0])
+
+    vmin = np.percentile(dark_mean.flatten(), 2)
+    vmax = np.percentile(dark_mean.flatten(), 98)
+    im1 = ax1.pcolormesh(dark_mean, vmin=vmin, vmax=vmax)
+    ax1.invert_yaxis()
+    ax1.set_aspect('equal')
+    cbar1 = _add_colorbar(im1, ax=ax1, size='2%')
+    cbar1.ax.set_ylabel('dark mean')
+
+    ax11.hist(dark_mean.flatten(), bins=int(vmax*2-vmin/2+1),
+            range=(vmin/2, vmax*2))
+    if mean_th[0] is not None:
+        ax11.axvline(mean_th[0], c='k', alpha=0.5, ls='--')
+    if mean_th[1] is not None:
+        ax11.axvline(mean_th[1], c='k', alpha=0.5, ls='--')
+    ax11.set_yscale('log')
+
+    vmin = np.percentile(dark_std.flatten(), 2)
+    vmax = np.percentile(dark_std.flatten(), 98)
+    im2 = ax2.pcolormesh(dark_std, vmin=vmin, vmax=vmax)
+    ax2.invert_yaxis()
+    ax2.set_aspect('equal')
+    cbar2 = _add_colorbar(im2, ax=ax2, size='2%')
+    cbar2.ax.set_ylabel('dark std')
+
+    ax22.hist(dark_std.flatten(), bins=50, range=(vmin/2, vmax*2))
+    if std_th[0] is not None:
+        ax22.axvline(std_th[0], c='k', alpha=0.5, ls='--')
+    if std_th[1] is not None:
+        ax22.axvline(std_th[1], c='k', alpha=0.5, ls='--')
+    ax22.set_yscale('log')
+
+    return fig
+
+
+def inspect_plane_fitting(avg, rois, vmin=None, vmax=None):
+    """Extract beams roi from average image and compute the ratio.
+
+    Inputs
+    ------
+    avg: module average image with no saturated shots for the flat field
+         determination
+    rois: dictionnary or rois containing the 3 beams ['n', '0', 'p'] with '0'
+          as the reference beam in the middle
+    vmin: imshow vmin level, default None will use 5 percentile value
+    vmax: imshow vmax level, default None will use 99.8 percentile value
+
+    Returns
+    -------
+    fig: matplotlib figure plotted
+    """
+    if vmin is None:
+        vmin = np.percentile(avg, 5)
+    if vmax is None:
+        vmax = np.percentile(avg, 99.8)
+
+    fig, axs = plt.subplots(2, 3, sharex=True, figsize=(6, 6))
+
+    img_rois = {}
+
+    for k, r in enumerate(['n', '0', 'p']):
+        img_rois[r] = avg[rois[r]['yl']:rois[r]['yh'],
+                          rois[r]['xl']:rois[r]['xh']]
+        im = axs[0, k].imshow(img_rois[r],
+                              vmin=vmin,
+                              vmax=vmax)
+
+    for k, r in enumerate(['n', '0', 'p']):
+        if img_rois[r].shape[1] != img_rois['0'].shape[1]:
+            if k == 0:
+                n1 = img_rois[r].shape[1]
+                n = img_rois['0'].shape[1]
+                v = img_rois[r][:, (n1-n):]/img_rois['0']
+            else:
+                n1 = img_rois[r].shape[1]
+                n = img_rois['0'].shape[1]
+                v = img_rois[r][:, :-(n1-n)]/img_rois['0']
+        else:
+            v = img_rois[r]/img_rois['0']
+        im2 = axs[1, k].imshow(v, vmin=0.2, vmax=1.1, cmap='RdBu_r')
+
+    cbar = fig.colorbar(im, ax=axs[0, :], orientation="horizontal")
+    cbar.ax.set_xlabel('data mean')
+
+    cbar = fig.colorbar(im2, ax=axs[1, :], orientation="horizontal")
+    cbar.ax.set_xlabel('ratio')
+
+    # fig.suptitle(f'{proposalNB}-run{runNB}-dark{darkrunNB} sat={sat_level}')
+
+    return fig
+
+
+def plane_fitting_domain(avg, rois):
+    """Extract beams roi, compute their ratio and the domain.
+
+    Inputs
+    ------
+    avg: module average image with no saturated shots for the flat field
+         determination
+    rois: dictionnary or rois containing the 3 beams ['n', '0', 'p'] with '0'
+          as the reference beam in the middle
+
+    Returns
+    -------
+    n: img ratio 'n'/'0'
+    n_m: mask where the the product 'n'*'0' is higher than 5 indicting that the
+         img ratio 'n'/'0' is defined
+    p: img ratio 'p'/'0'
+    p_m: mask where the the product 'p'*'0' is higher than 5 indicting that the
+         img ratio 'p'/'0' is defined
+    """
+    centers = {}
+
+    for k, r in enumerate(['n', '0', 'p']):
+        centers[r] = np.array([(rois[r]['yl'] + rois[r]['yh'])//2,
+                      (rois[r]['xl'] + rois[r]['xh'])//2])
+
+    k = 'n'
+    num = avg[rois[k]['yl']:rois[k]['yh'], rois[k]['xl']:rois[k]['xh']]
+    d = '0'
+    denom = np.roll(avg, tuple(centers[k] - centers[d]))[
+        rois[k]['yl']:rois[k]['yh'], rois[k]['xl']:rois[k]['xh']]
+    n = num/denom
+    n_m = ((num*denom) > 5) * (num/denom < 1.2)
+    n_m[~np.isfinite(n)] = 0
+    n[~np.isfinite(n)] = 0
+
+    k = 'p'
+    num = avg[rois[k]['yl']:rois[k]['yh'], rois[k]['xl']:rois[k]['xh']]
+    d = '0'
+    denom = np.roll(avg, tuple(centers[k] - centers[d]))[
+        rois[k]['yl']:rois[k]['yh'], rois[k]['xl']:rois[k]['xh']]
+    p = num/denom
+    p_m = ((num*denom) > 5) * (num/denom < 1.2)
+    p_m[~np.isfinite(p)] = 0
+    p[~np.isfinite(p)] = 0
+
+    return n, n_m, p, p_m
+
+
+def plane_fitting(params):
+    """Fit the plane flat field normalization.
+
+    Inputs
+    ------
+    params: parameters
+
+    Returns
+    -------
+    res: the minimization result. The fitted vector res.x = [a, b, c, d]
+        defines the plane as a*x + b*y + c*z + d = 0
+    """
+    assert params.arr_dark is not None, "Data not loaded"
+    dark = average_module(params.arr_dark).compute()
+    assert params.arr is not None, "Data not loaded"
+    data = average_module(params.arr, dark=dark,
+        ret='mean', mask=params.mask, sat_roi=params.rois['sat'],
+        sat_level=params.sat_level).compute()
+    data_mean = data.mean(axis=0)  # mean over pulseId
+
+    n, n_m, p, p_m = plane_fitting_domain(data_mean, params.rois)
+
+    def _crit(x):
+        """Fitting criteria for the plane field normalization.
+
+        Inputs
+        ------
+        x: vector [a, b, c, d] defining the plane as
+                a*x + b*y + c*z + d = 0
+        """
+        a, b, c, d = x
+
+        num = a**2 + b**2 + c**2
+
+        nY, nX = n.shape
+        X = np.arange(nX)/100
+        Y = np.arange(nY)[:, np.newaxis]/100
+        d0_2 = np.sum(n_m*(a*X + b*Y + c*n + d)**2)/num
+
+        nY, nX = p.shape
+        X = np.arange(nX)/100
+        Y = np.arange(nY)[:, np.newaxis]/100
+        d2_2 = np.sum(np.fliplr(p_m)*(a*X + b*Y + c*np.fliplr(p) + d)**2)/num
+
+        return d2_2 + d0_2
+
+    p_guess_fit = [-0.2, -0.1, 1, -0.54]
+
+    res = minimize(_crit, p_guess_fit)
+
+    return res
+
+
+def process_module(arr, tid, dark, rois, mask=None, sat_level=511,
+                   flat_field=None, F_INL=None):
+    """Process one module and extract roi intensity.
+
+    Inputs
+    ------
+    arr: dask array of reshaped dssc data (trainId, pulseId, x, y)
+    tid: array of train id number
+    dark: pulse resolved dark image to remove
+    rois: dictionnary of rois
+    mask: default None, mask of ignored pixels
+    sat_level: integer, default 511, at which level pixel begin to saturate
+    flat_field: default None, flat field correction
+    F_INL: default None, non linear correction function given as a
+           lookup table with 9 bits integer input
+
+    Returns
+    -------
+    dataset of extracted pulse and train resolved roi intensities.
+    """
+    # F_INL
+    if F_INL is not None:
+        narr = arr.map_blocks(lambda x: F_INL[x])
+    else:
+        narr = arr
+
+    # apply mask
+    if mask is not None:
+        narr = narr*mask
+
+    # crop rois
+    r = {}
+    rd = {}
+    for n in rois.keys():
+        r[n] = narr[:, :, rois[n]['yl']:rois[n]['yh'],
+                    rois[n]['xl']:rois[n]['xh']]
+        rd[n] = dark[:, rois[n]['yl']:rois[n]['yh'],
+                     rois[n]['xl']:rois[n]['xh']]
+
+    # find saturated shots
+    r_sat = {}
+    for n in rois.keys():
+        r_sat[n] = da.any(r[n] >= sat_level, axis=(2, 3))
+
+    # TODO: flat field should not be applied on intra darks
+    # # change flat field dimension to match data
+    # if flat_field is not None:
+    #     temp = np.ones_like(dark)
+    #     temp[::2, :, :] = flat_field[:, :]
+    #    flat_field = temp
+
+    # compute dark corrected ROI values
+    v = {}
+    for n in rois.keys():
+
+        r[n] = r[n] - rd[n]
+
+        if flat_field is not None:
+            # TODO:  flat field should not be applied on intra darks
+            # ff = flat_field[:, rois[n]['yl']:rois[n]['yh'],
+            #                 rois[n]['xl']:rois[n]['xh']]
+            ff = flat_field[rois[n]['yl']:rois[n]['yh'],
+                            rois[n]['xl']:rois[n]['xh']]
+            r[n] = r[n]/ff
+
+        v[n] = r[n].sum(axis=(2, 3))
+
+    res = xr.Dataset()
+
+    dims = ['trainId', 'pulseId']
+    r_coords = {'trainId': tid, 'pulseId': np.arange(0, narr.shape[1])}
+    for n in rois.keys():
+        res[n + '_sat'] = xr.DataArray(r_sat[n][:, :],
+                                       coords=r_coords, dims=dims)
+        res[n] = xr.DataArray(v[n], coords=r_coords, dims=dims)
+
+    for n in rois.keys():
+        roi = rois[n]
+        res[n + '_area'] = xr.DataArray(np.array([
+            (roi['yh'] - roi['yl'])*(roi['xh'] - roi['xl'])]))
+
+    return res
+
+
+def process(Fmodel, arr_dark, arr, tid, rois, mask, flat_field, sat_level=511):
+    """Process dark and run data with corrections.
+
+    Inputs
+    ------
+    Fmodel: correction lookup table
+    arr_dark: dark data
+    arr: data
+    rois: ['n', '0', 'p', 'sat'] rois
+    mask: mask of good pixels
+    flat_field: zone plate flat field correction
+    sat_level: integer, default 511, at which level pixel begin to saturate
+
+    Returns
+    -------
+    roi extracted intensities
+    """
+    # dark process
+    res = average_module(arr_dark, F_INL=Fmodel)
+    dark = res.compute()
+
+    # data process
+    proc = process_module(arr, tid, dark, rois, mask, sat_level=sat_level,
+                          flat_field=flat_field, F_INL=Fmodel)
+    data = proc.compute()
+
+    return data
+
+
+def nl_crit(p, domain, alpha, arr_dark, arr, tid, rois, mask, flat_field,
+    sat_level=511):
+    """Criteria for the non linear correction.
+
+    Inputs
+    ------
+    p: vector of dy non linear correction
+    domain: domain over which the non linear correction is defined
+    alpha: float, coefficient scaling the cost of the correction function
+        in the criterion
+    arr_dark: dark data
+    arr: data
+    tid: train id of arr data
+    rois: ['n', '0', 'p', 'sat'] rois
+    mask: mask fo good pixels
+    flat_field: zone plate flat field correction
+    sat_level: integer, default 511, at which level pixel begin to saturate
+
+    Returns
+    -------
+    (1.0 - alpha)*err1 + alpha*err2, where err1 is the 1e8 times the mean of
+    error squared from a transmission of 1.0 and err2 is the sum of the square
+    of the deviation from the ideal detector response.
+    """
+    Fmodel = nl_lut(domain, p)
+    data = process(Fmodel, arr_dark, arr, tid, rois, mask, flat_field,
+        sat_level)
+
+    # drop saturated shots
+    d = data.where(data['sat_sat'] == False, drop=True)
+
+    # calculated error from transmission of 1.0
+    v = d['n'].values.flatten()/d['0'].values.flatten()
+    err1 = 1e8*np.nanmean((v - 1.0)**2)
+
+    err2 = np.sum((Fmodel-np.arange(2**9))**2)
+
+    # print(f'{err}: {p}')
+    # logging.info(f'{err}: {p}')
+
+    return (1.0 - alpha)*err1 + alpha*err2
+
+
+def nl_fit(params, domain):
+    """Fit non linearities correction function.
+
+    Inputs
+    ------
+    params: parameters
+    domain: array of index
+
+    Returns
+    -------
+    res: scipy minimize result. res.x is the optimized parameters
+
+    firres: iteration index arrays of criteria results for
+        [alpha=0, alpha, alpha=1]
+    """
+    # load data
+    assert params.arr is not None, "Data not loaded"
+    assert params.arr_dark is not None, "Data not loaded"
+
+    # we only need few rois
+    fitrois = {}
+    for k in ['n', '0', 'p', 'sat']:
+        fitrois[k] = params.rois[k]
+
+    # p0
+    N = np.unique(domain).shape[0] - 1
+    p0 = np.array([0]*N)
+
+    # flat flat_field
+    ff = compute_flat_field_correction(params.rois, params.get_flat_field())
+
+    fixed_p = (domain, params.alpha, params.arr_dark, params.arr, params.tid,
+        fitrois, params.get_mask(), ff, params.sat_level)
+
+    def fit_callback(x):
+        if not hasattr(fit_callback, "counter"):
+            fit_callback.counter = 0  # it doesn't exist yet, so initialize it
+            fit_callback.start = time.monotonic()
+            fit_callback.res = []
+
+        now = time.monotonic()
+        time_delta = datetime.timedelta(seconds=now-fit_callback.start)
+        fit_callback.counter += 1
+
+        temp = list(fixed_p)
+        Jalpha = nl_crit(x, *temp)
+        temp[1] = 0
+        J0 = nl_crit(x, *temp)
+        temp[1] = 1
+        J1 = nl_crit(x, *temp)
+        fit_callback.res.append([J0, Jalpha, J1])
+        print(f'{fit_callback.counter-1}: {time_delta} '
+                f'({J0}, {Jalpha}, {J1}), {x}')
+
+        return False
+
+    fit_callback(p0)
+    res = minimize(nl_crit, p0, fixed_p,
+        options={'disp': True, 'maxiter': params.max_iter},
+        callback=fit_callback)
+
+    return res, fit_callback.res
+
+
+def inspect_nl_fit(res_fit):
+    """Plot the progress of the fit.
+
+    Inputs
+    ------
+    res_fit:
+
+    Returns
+    -------
+    matplotlib figure
+    """
+    r = np.array(res_fit)
+    f = plt.figure(figsize=(6, 4))
+    ax = f.gca()
+    ax2 = plt.twinx()
+    ax.plot(1.0/np.sqrt(1e-8*r[:, 0]), c='C0')
+    ax2.plot(r[:, 2], c='C1', ls='-.')
+    ax.set_xlabel('# iteration')
+    ax.set_ylabel('SNR', c='C0')
+    ax2.set_ylabel('correction cost', c='C1')
+    ax.set_yscale('log')
+    ax2.set_yscale('log')
+
+    return f
+
+
+def snr(sig, ref, verbose=False):
+    """ Compute mean, std and SNR with and without weight from transmitted signal sig
+        and I0 signal ref
+    """
+    w = ref
+    x = sig/ref
+
+    mask = np.isfinite(x) & np.isfinite(sig) & np.isfinite(ref)
+    
+    w = w[mask]
+    sig = sig[mask]
+    ref = ref[mask]
+    x = x[mask]
+
+    # direct mean and std
+    mu = np.mean(x)
+    s = np.std(x)
+    if verbose:
+        print(f'mu: {mu}, s: {s}, snr: {mu/s}')
+    res = {}
+    res['direct'] = {'mu': mu, 's':s}
+
+    # weighted mean and std
+    wmu = np.sum(sig)/np.sum(ref)
+    v1 = np.sum(w)
+    v2 = np.sum(w**2)
+    ws = np.sqrt(np.sum(w*(x - wmu)**2)/(v1 - v2/v1))
+
+    if verbose:
+        print(f'weighted mu: {wmu}, s: {ws}, snr: {wmu/ws}')
+    res['weighted'] = {'mu': wmu, 's':ws}
+
+    # noise from diff
+    dmu = np.mean(x)
+    ds = np.std(np.diff(x))/np.sqrt(2)
+    if verbose:
+        print(f'diff mu: {dmu}, s: {ds}, snr: {dmu/ds}')
+    res['diff'] = {'mu': dmu, 's':ds}
+
+    return res
+
+
+def inspect_correction(params, gain=None):
+    """Criteria for the non linear correction.
+
+    Inputs
+    ------
+    params: parameters
+    gain: float, default None, DSSC gain in ph/bin
+
+    Returns
+    -------
+    matplotlib figure
+    """
+    # load data
+    assert params.arr is not None, "Data not loaded"
+    assert params.arr_dark is not None, "Data not loaded"
+
+    # we only need few rois
+    fitrois = {}
+    for k in ['n', '0', 'p', 'sat']:
+        fitrois[k] = params.rois[k]
+
+    # flat flat_field
+    ff = compute_flat_field_correction(params.rois, params.get_flat_field())
+
+    data = process(np.arange(2**9), params.arr_dark, params.arr, params.tid,
+        fitrois, params.get_mask(), np.ones_like(ff), params.sat_level)
+    data_ff = process(np.arange(2**9), params.arr_dark, params.arr, params.tid,
+        fitrois, params.get_mask(), ff, params.sat_level)
+    data_ff_nl = process(params.get_Fnl(), params.arr_dark, params.arr,
+        params.tid, fitrois, params.get_mask(), ff, params.sat_level)
+
+    # for conversion to nb of photons
+    if gain is None:
+        g = 1
+    else:
+        g = gain
+
+    scale = 1e-6
+
+    f, axs = plt.subplots(3, 3, figsize=(6, 6), sharex=True, sharey=True)
+
+    # nbins = np.linspace(0.01, 1.0, 100)
+
+    from matplotlib.colors import LogNorm
+
+    photon_scale = None
+
+    for k, d in enumerate([data, data_ff, data_ff_nl]):
+        for l, (n, r) in enumerate([('n', '0'), ('p', '0'), ('n', 'p')]):
+
+            if photon_scale is None:
+                lower = 0
+                upper = g*scale*np.percentile(d['0'].values.flatten(), 99.9)
+                photon_scale = np.linspace(lower, upper, 150)
+
+            good_d = d.where(d['sat_sat'] == False, drop=True)
+            sat_d = d.where(d['sat_sat'], drop=True)
+
+            snr_v = snr(good_d[n].values.flatten(),
+                        good_d[r].values.flatten(), verbose=True)
+
+            if k == 0:
+                m = np.nanmean(good_d[n].values.flatten()
+                    /good_d[r].values.flatten())
+            else:
+                m = 1
+
+            h, xedges, yedges, img = axs[l, k].hist2d(
+                g*scale*good_d[r].values.flatten(),
+                good_d[n].values.flatten()/good_d[r].values.flatten()/m,
+                [photon_scale, np.linspace(0.95, 1.05, 150)],
+                cmap='Blues',
+                vmax=200,
+                norm=LogNorm(),
+                # alpha=0.5 # make  the plot looks ugly with lots of white lines
+                )
+            h, xedges, yedges, img2 = axs[l, k].hist2d(
+                g*scale*sat_d[r].values.flatten(),
+                sat_d[n].values.flatten()/sat_d[r].values.flatten()/m,
+                [photon_scale, np.linspace(0.95, 1.05, 150)],
+                cmap='Reds',
+                vmax=200,
+                norm=LogNorm(),
+                # alpha=0.5 # make  the plot looks ugly with lots of white lines
+                )
+            v = snr_v['direct']['mu']/snr_v['direct']['s']
+            axs[l, k].text(0.4, 0.15, f'SNR: {v:.0f}',
+                            transform = axs[l, k].transAxes)
+            v = snr_v['weighted']['mu']/snr_v['weighted']['s']
+            axs[l, k].text(0.4, 0.05, f'wSNR: {v:.0f}',
+                            transform = axs[l, k].transAxes)
+
+            # axs[l, k].plot(3*nbins, 1+np.sqrt(2/(1e6*nbins)), c='C1', ls='--')
+            # axs[l, k].plot(3*nbins, 1-np.sqrt(2/(1e6*nbins)), c='C1', ls='--')
+
+    for k in range(3):
+        for l in range(3):
+            axs[l, k].set_ylim([0.95, 1.05])
+        if gain:
+            axs[2, k].set_xlabel('#ph (10$^6$)')
+        else:
+            axs[2, k].set_xlabel('ADU (10$^6$)')
+
+    f.colorbar(img, ax=axs, label='counts')
+
+    axs[0, 0].set_title('raw')
+    axs[0, 1].set_title('flat field')
+    axs[0, 2].set_title('non-linear')
+
+    axs[0, 0].set_ylabel(r'-1$^\mathrm{st}$/0$^\mathrm{th}$ order')
+    axs[1, 0].set_ylabel(r'1$^\mathrm{st}$/0$^\mathrm{th}$ order')
+    axs[2, 0].set_ylabel(r'-1$^\mathrm{st}$/1$^\mathrm{th}$ order')
+
+    return f
+
+
+def inspect_Fnl(Fnl):
+    """Plot the correction function Fnl.
+
+    Inputs
+    ------
+    Fnl: non linear correction function lookup table
+
+    Returns
+    -------
+    matplotlib figure
+    """
+    x = np.arange(2**9)
+    f = plt.figure(figsize=(6, 4))
+
+    plt.plot(x, Fnl - x)
+    # plt.axvline(40, c='k', ls='--')
+    # plt.axvline(280, c='k', ls='--')
+    plt.xlabel('input value')
+    plt.ylabel('output correction F(x)-x')
+    plt.xlim([0, 511])
+
+    return f