Repository: legend-exp/pygama (language: python)

Issue:
Cleaned up distributions and added a sum_dist class
Before submitting a pull request, please make sure you've read and understood the pygama developer's guide: https://pygama.readthedocs.io/en/latest/developer.html. In particular, do not forget to:

- [x] Conform to our **coding conventions**
- [x] Update existing or add new **tests**
- [x] Update existing or add new **documentation**
- [x] Address any issue reported by GitHub checks

Fix for seaborn deprecation in matplotlib
A fix for the seaborn deprecation in https://github.com/legend-exp/pygama/issues/410. I am unable to test this myself due to some weird python environment stuff, so I'm relying on the git tests here.

Before submitting a pull request, please make sure you've read and understood the pygama developer's guide: https://pygama.readthedocs.io/en/latest/developer.html. In particular, do not forget to:

- [ ] Conform to our **coding conventions**
- [ ] Update existing or add new **tests**
- [ ] Update existing or add new **documentation**
- [ ] Address any issue reported by GitHub checks

weird sphinx-build behavior
While investigating the build docs error in https://github.com/legend-exp/pygama/pull/341 I found that I can put the following lines anywhere in pygama (I tried e.g. lgdo/__init__.py)
```
import scipy
print(hasattr(scipy,"__version__"))
print("__version__" in scipy.__dict__.keys())
print(scipy.__version__)
print(hasattr(scipy,"nonsense"))
print("nonsense" in scipy.__dict__.keys())
```
and in an interactive python session it gives this expected output:
```
True
True
1.8.1
False
False
```
while during sphinx-build (which runs during `make` inside of the docs directory) it gives this nonsensical output:
```
True
False
scipy.__version__
True
False
```
Indeed, printing `scipy.__dict__.keys()` lists
`dict_keys(['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__all__', '__path__', 'optimize', 'stats', 'sparse'])`
during sphinx-build, and many more things during a standard interactive python session.

So then I made my own single-file module with the six lines at the top of this post, and I built sphinx documentation for that file according to these instructions:
https://tobywf.com/2019/08/running-sphinx-on-a-single-python-file/
During sphinx-build, the following was printed:
```
True
True
1.8.1
False
False
```
which is correct.

I conclude that this is not (obviously) a bug in scipy or sphinx but has to do with our specific usage of sphinx. These 6 lines need to work for us so that we can build documentation for code that uses e.g. `scikit-learn` which checks the scipy version during import.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/distribution.yml b/.github/workflows/distribution.yml
new file mode 100644
index 000000000..ac50ba589
--- /dev/null
+++ b/.github/workflows/distribution.yml
@@ -0,0 +1,43 @@
+name: distribute
+
+on:
+  workflow_dispatch:
+  push:
+    tags: "v*"
+  release:
+    types:
+      - published
+
+jobs:
+
+  dist:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v3
+      with:
+        fetch-depth: 0
+
+    - name: Build SDist and wheel
+      run: pipx run build
+
+    - uses: actions/upload-artifact@v3
+      with:
+        path: dist/*
+
+    - name: Check metadata
+      run: pipx run twine check dist/*
+
+  publish:
+    needs: [dist]
+    runs-on: ubuntu-latest
+    if: github.event_name == 'release' && github.event.action == 'published'
+
+    steps:
+    - uses: actions/download-artifact@v3
+      with:
+        name: artifact
+        path: dist
+
+    - uses: pypa/gh-action-pypi-publish@v1.5.1
+      with:
+        password: ${{ secrets.pypi_password }}
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index d4c5de7e2..f6056bd6b 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -9,6 +9,10 @@ on:
   pull_request:
   release:
 
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
 jobs:
 
   build-and-test:
@@ -36,7 +40,6 @@ jobs:
 
   test-coverage:
     name: Calculate and upload test coverage
-    needs: build-and-test
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
@@ -53,10 +56,12 @@ jobs:
           pytest --cov=pygama --cov-report=xml
       - name: Upload Coverage to codecov.io
         uses: codecov/codecov-action@v3
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          fail_ci_if_error: true
 
   test-docs:
     name: Build documentation
-    needs: build-and-test
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v2
@@ -67,8 +72,9 @@ jobs:
           python-version: '3.10'
       - name: Setup build environment
         run: |
+          sudo apt-get install -y pandoc
           python -m pip install --upgrade pip wheel setuptools
-          python -m pip install --upgrade .[docs]
+          python -m pip install --upgrade .[all]
       - name: Build docs for current ref
         run: |
           cd docs
diff --git a/.gitignore b/.gitignore
index 0299fa5dd..78d35c84d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -79,7 +79,7 @@ instance/
 
 # Sphinx documentation
 /docs/build/
-/docs/source/generated
+/docs/source/api
 
 # PyBuilder
 target/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 598761b49..59f24633d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,7 +3,7 @@ ci:
   autoupdate_schedule: "monthly"
   autofix_commit_msg: "style: pre-commit fixes"
 
-exclude: ^(attic|src/pygama/math|src/pygama/pargen|src/pygama/flow/datagroup.py)
+exclude: ^(attic|tutorials|src/pygama/math|src/pygama/flow/datagroup.py)
 repos:
 - repo: https://github.com/pre-commit/pre-commit-hooks
   rev: "v4.3.0"
@@ -25,7 +25,7 @@ repos:
   - id: trailing-whitespace
 
 - repo: https://github.com/asottile/setup-cfg-fmt
-  rev: "v2.0.0"
+  rev: "v2.2.0"
   hooks:
   - id: setup-cfg-fmt
 
@@ -35,33 +35,35 @@ repos:
   - id: isort
 
 - repo: https://github.com/asottile/pyupgrade
-  rev: "v2.37.3"
+  rev: "v3.2.0"
   hooks:
   - id: pyupgrade
     args: ["--py38-plus"]
 
 - repo: https://github.com/psf/black
-  rev: "22.6.0"
+  rev: "22.10.0"
   hooks:
-  - id: black
+  - id: black-jupyter
 
-# - repo: https://github.com/pre-commit/mirrors-mypy
-#   rev: "v0.961"
-#   hooks:
-#     - id: mypy
-#       files: src
-#       args: []
+- repo: https://github.com/pre-commit/mirrors-mypy
+  rev: "v0.982"
+  hooks:
+  - id: mypy
+    files: src
+    stages: [manual]
 
 - repo: https://github.com/hadialqattan/pycln
   rev: "v2.1.1"
   hooks:
-    - id: pycln
-      args: [--all]
+  - id: pycln
+    exclude: ^src/pygama/pargen
+    args: [--all]
 
 - repo: https://github.com/PyCQA/flake8
-  rev: "5.0.2"
+  rev: "5.0.4"
   hooks:
   - id: flake8
+    exclude: ^src/pygama/pargen
     additional_dependencies: [
       flake8-bugbear,
       flake8-print,
@@ -71,7 +73,7 @@ repos:
     # args: ["--docstring-convention", "numpy"]
 
 - repo: https://github.com/kynan/nbstripout
-  rev: "0.6.0"
+  rev: "0.6.1"
   hooks:
     - id: nbstripout
       args: ["--drop-empty-cells",
@@ -84,10 +86,9 @@ repos:
     stages: [manual]
 
 - repo: https://github.com/codespell-project/codespell
-  rev: "v2.1.0"
+  rev: "v2.2.2"
   hooks:
   - id: codespell
-    args: ["-L", "hist,gaus,nd,ans,crate,nin,puls,spms"]
 
 - repo: https://github.com/shellcheck-py/shellcheck-py
   rev: "v0.8.0.4"
@@ -103,7 +104,7 @@ repos:
   - id: rst-inline-touching-normal
 
 - repo: https://github.com/pre-commit/mirrors-prettier
-  rev: "v2.7.1"
+  rev: "v3.0.0-alpha.4"
   hooks:
     - id: prettier
       types_or: [json]
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 56157945d..40e04811b 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -3,18 +3,24 @@ version: 2
 sphinx:
   configuration: docs/source/conf.py
 
+formats:
+  - epub
+  - pdf
+
 build:
   os: "ubuntu-22.04"
+  apt_packages:
+    - pandoc
   tools:
     python: "3.10"
   jobs:
     pre_build:
-      - rm -rf docs/source/generated
+      - rm -rf docs/source/api
       - sphinx-apidoc
         --private
         --module-first
         --force
-        --output-dir docs/source/generated
+        --output-dir docs/source/api
         src/pygama
         src/pygama/_version.py
 
@@ -23,4 +29,4 @@ python:
     - method: pip
       path: .
       extra_requirements:
-        - docs
+        - all
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 000000000..7dcc0bb0c
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,6 @@
+prune **
+graft src
+graft tests
+
+include LICENSE README.md pyproject.toml setup.py setup.cfg
+global-exclude __pycache__ *.py[cod] .*
diff --git a/README.md b/README.md
index fa76c6f9e..54891ffcd 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,14 @@
 # pygama
 
-![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/legend-exp/pygama?logo=git)
+[![PyPI](https://img.shields.io/pypi/v/pygama?logo=pypi)](https://pypi.org/project/pygama/)
+[![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/legend-exp/pygama?logo=git)](https://github.com/legend-exp/pygama/tags)
 [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/legend-exp/pygama/pygama/main?label=main%20branch&logo=github)](https://github.com/legend-exp/pygama/actions)
 [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
 [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
 [![Codecov](https://img.shields.io/codecov/c/github/legend-exp/pygama?logo=codecov)](https://app.codecov.io/gh/legend-exp/pygama)
-![GitHub issues](https://img.shields.io/github/issues/legend-exp/pygama?logo=github)
-![GitHub pull requests](https://img.shields.io/github/issues-pr/legend-exp/pygama?logo=github)
-![License](https://img.shields.io/github/license/legend-exp/pygama)
+[![GitHub issues](https://img.shields.io/github/issues/legend-exp/pygama?logo=github)](https://github.com/legend-exp/pygama/issues)
+[![GitHub pull requests](https://img.shields.io/github/issues-pr/legend-exp/pygama?logo=github)](https://github.com/legend-exp/pygama/pulls)
+[![License](https://img.shields.io/github/license/legend-exp/pygama)](https://github.com/legend-exp/pygama/blob/main/LICENSE)
 [![Read the Docs](https://img.shields.io/readthedocs/pygama?logo=readthedocs)](https://pygama.readthedocs.io)
 
 *pygama* is a Python package for:
diff --git a/docs/Makefile b/docs/Makefile
index 8e69f8bb9..4eb19564e 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -7,11 +7,11 @@ all: apidoc
 
 apidoc: clean-apidoc
 	sphinx-apidoc --private --module-first --force \
-      --output-dir "$(SOURCEDIR)/generated" \
+      --output-dir "$(SOURCEDIR)/api" \
       ../src/pygama ../src/pygama/_version.py
 
 clean-apidoc:
-	rm -rf "$(SOURCEDIR)/generated"
+	rm -rf "$(SOURCEDIR)/api"
 
 clean: clean-apidoc
 	rm -rf "$(BUILDDIR)"
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 6b300dee4..0d5724b3c 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -18,6 +18,8 @@
     "sphinx.ext.napoleon",
     "sphinx.ext.intersphinx",
     "sphinx_copybutton",
+    "sphinx_inline_tabs",
+    "nbsphinx",
     "myst_parser",
 ]
 
@@ -26,7 +28,6 @@
     ".md": "markdown",
 }
 master_doc = "index"
-language = "python"
 
 # Furo theme
 html_theme = "furo"
@@ -37,31 +38,6 @@
 }
 html_title = f"{project} {version}"
 
-# list here pygama dependencies that are not required for building docs and
-# could be unmet at build time
-autodoc_mock_imports = [
-    "pygama._version",
-    "pandas",
-    # 'numpy',
-    "matplotlib",
-    "mplhep",
-    "scipy",
-    "numba",
-    "pytest",
-    "pyhf",
-    "awkward",
-    "iminuit",
-    "boost-histogram",
-    "hepunits",
-    "hepstats",
-    "uproot",
-    "h5py",
-    "pint",
-    "pyfftw",
-    "tqdm",
-    "tinydb",
-    "parse",
-]
 autodoc_default_options = {"ignore-module-all": True}
 
 # sphinx-napoleon
@@ -69,6 +45,7 @@
 napoleon_numpy_docstring = True
 napoleon_google_docstring = False
 napoleon_use_ivar = True
+napoleon_use_rtype = False
 napoleon_custom_sections = ["JSON Configuration Example"]
 
 
@@ -91,3 +68,15 @@
 autodoc_typehints = "both"
 autodoc_typehints_description_target = "documented_params"
 autodoc_typehints_format = "short"
+
+# nbsphinx
+nbsphinx_epilog = """
+----
+
+This page has been automatically generated by nbsphinx_ and can be run as a
+Jupyter_ notebook available in the `pygama repository
+<https://github.com/legend-exp/pygama/tree/main/docs/source/notebooks>`_.
+
+.. _nbsphinx: https://nbsphinx.readthedocs.io/
+.. _Jupyter: https://jupyter.org/
+"""
diff --git a/docs/source/developer.rst b/docs/source/developer.rst
index 70985f32d..d69f7c9e6 100644
--- a/docs/source/developer.rst
+++ b/docs/source/developer.rst
@@ -5,11 +5,15 @@ The following rules and conventions have been established for the package
 development and are enforced throughout the entire code base. Merge requests
 that do not comply to the following directives will be rejected.
 
-To start developing :mod:`pygama`, clone the remote repository:
+To start developing :mod:`pygama`, fork the remote repository to your personal
+GitHub account (see `About Forks <https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/about-forks>`_).
+If you have not set up your ssh keys on the computer you will be working on,
+please follow `GitHub's instructions <https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent>`_. Once you have your own fork, you can clone it via
+(replace "yourusername" with your GitHub username):
 
 .. code-block:: console
 
-  $ git clone https://github.com/legend-exp/pygama
+  $ git clone git@github.com:yourusername/pygama.git
 
 All extra tools needed to develop :mod:`pygama` are listed as optional
 dependencies and can be installed via pip by running:
@@ -121,6 +125,74 @@ Testing
 
     $ pytest --cov=pygama
 
+Testing Numba-Wrapped Functions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+When using Numba to vectorize Python functions, the Python version of the function
+does not, by default, get directly tested, but the Numba version instead. In
+this case, we need to unwrap the Numba function and test the pure Python version.
+With various processors in :mod:`pygama.dsp.processors`, this means that testing
+and triggering the code coverage requires this unwrapping.
+
+Within the testing suite, we use the :func:`@pytest.fixture()<pytest.fixture>`
+decorator to include a helper function called ``compare_numba_vs_python`` that
+can be used in any test. This function runs both the Numba and pure Python versions
+of a function, asserts that they are equal up to floating precision, and returns the
+output value.
+
+As an example, we show a snippet from the test for
+:func:`pygama.dsp.processors.fixed_time_pickoff`, a processor which uses the
+:func:`@numba.guvectorize()<numba.guvectorize>` decorator.
+
+.. code-block:: python
+
+    def test_fixed_time_pickoff(compare_numba_vs_python):
+        """Testing function for the fixed_time_pickoff processor."""
+
+        len_wf = 20
+
+        # test for nan if w_in has a nan
+        w_in = np.ones(len_wf)
+        w_in[4] = np.nan
+        assert np.isnan(compare_numba_vs_python(fixed_time_pickoff, w_in, 1, ord("i")))
+
+In the assertion that the output is what we expect, we use
+``compare_numba_vs_python(fixed_time_pickoff, w_in, 1, ord("i"))`` in place of
+``fixed_time_pickoff(w_in, 1, ord("i"))``. In general, the replacement to make is
+``func(*inputs)`` becomes ``compare_numba_vs_python(func, *inputs)``.
+
+Note, that in cases of testing for the raising of errors, it is recommended
+to instead run the function twice: once with the Numba version, and once using the
+:func:`inspect.unwrap` function. We again show a snippet from the test for
+:func:`pygama.dsp.processors.fixed_time_pickoff` below. We include the various
+required imports in the snippet for verbosity.
+
+.. code-block:: python
+
+    import inspect
+
+    import numpy as np
+    import pytest
+
+    from pygama.dsp.errors import DSPFatal
+    from pygama.dsp.processors import fixed_time_pickoff
+
+    def test_fixed_time_pickoff(compare_numba_vs_python):
+    "skipping parts of function..."
+    # test for DSPFatal errors being raised
+    # noninteger t_in with integer interpolation
+    with pytest.raises(DSPFatal):
+        w_in = np.ones(len_wf)
+        fixed_time_pickoff(w_in, 1.5, ord("i"))
+
+    with pytest.raises(DSPFatal):
+        a_out = np.empty(len_wf)
+        inspect.unwrap(fixed_time_pickoff)(w_in, 1.5, ord("i"), a_out)
+
+In this case, the general idea is to use :func:`pytest.raises` twice, once with
+``func(*inputs)``, and again with ``inspect.unwrap(func)(*inputs)``.
+
+
 Documentation
 -------------
 
@@ -168,6 +240,9 @@ We adopt the following guidelines for writing documentation:
   <https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_numpy.html>`_.
 * We support also the Markdown format through the `MyST-Parser
   <https://myst-parser.readthedocs.io/en/latest/syntax/syntax.html>`_.
+* Jupyter notebooks placed below ``docs/source/notebooks`` are automatically
+  rendered to HTML pages by the `nbsphinx <https://nbsphinx.readthedocs.io>`_
+  extension.
 
 Building documentation
 ^^^^^^^^^^^^^^^^^^^^^^
@@ -181,7 +256,8 @@ required. You can get all the needed dependencies by running:
   $ cd pygama
   $ pip install '.[docs]'
 
-To build documentation, run the following commands:
+`Pandoc <https://pandoc.org/installing.html>`_ is also required to render
+Jupyter notebooks. To build documentation, run the following commands:
 
 .. code-block:: console
 
@@ -226,3 +302,9 @@ new project version must implement the following procedures:
 
        $ git push origin releases/vMAJOR.MINOR
        $ git push origin refs/tags/vMAJOR.MINOR.PATCH
+
+* To upload the release to the `Python Package Index
+  <https://pypi.org/project/pygama>`_, a new release must be created through
+  `the GitHub interface <https://github.com/legend-exp/pygama/releases/new>`_,
+  associated to the just created tag.  Usage of the "Generate release notes"
+  option is recommended.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index fb86d7a07..e08e4602e 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -1,21 +1,55 @@
 Welcome to pygama's documentation!
 ==================================
 
-`pygama <https://github.com/legend-exp/pygama>`_ is a Python package for:
+*pygama* is a Python package for:
 
 * converting physics data acquisition system output to LEGEND LH5-format HDF5 files
 * performing bulk digital signal processing on time-series data
 * optimizing those digital signal processing (DSP) routines and tuning associated analysis parameters
 * generating and selecting high-level event data for further analysis
 
-Table of Contents
------------------
+Getting started
+---------------
+
+*pygama* is published on the `Python Package Index
+<https://pypi.org/project/pygama>`_. Install on local systems with `pip
+<https://pip.pypa.io/en/stable/getting-started>`_:
+
+.. tab:: Stable release
+
+    .. code-block:: console
+
+        $ pip install pygama
+
+.. tab:: Unstable (``main`` branch)
+
+    .. code-block:: console
+
+        $ pip install pygama@git+https://github.com/legend-exp/pygama@main
+
+.. tab:: Linux Containers
+
+    Get a LEGEND container with *pygama* pre-installed on `Docker hub
+    <https://hub.docker.com/r/legendexp/legend-software>`_ or follow
+    instructions on the `LEGEND wiki
+    <https://legend-exp.atlassian.net/l/cp/nF1ww5KH>`_.
+
+If you plan to develop *pygama*, refer to the :doc:`developer's guide
+<developer>`.
+
+.. attention::
+
+    If installing in a user directory (typically when invoking pip as a normal
+    user), make sure ``~/.local/bin`` is appended to ``PATH``. The ``pygama``
+    executable is installed there.
+
+Next steps
+----------
 
 .. toctree::
    :maxdepth: 1
 
-   install
    manuals/index
    tutorials
-   Package API reference <generated/modules>
+   Package API reference <api/modules>
    developer
diff --git a/docs/source/install.rst b/docs/source/install.rst
deleted file mode 100644
index 6094b8302..000000000
--- a/docs/source/install.rst
+++ /dev/null
@@ -1,31 +0,0 @@
-Installation
-============
-
-Install on local systems with:
-
-.. code-block:: console
-
-    $ git clone https://github.com/legend-exp/pygama
-    $ pip install pygama
-
-Append the ``-e`` flag (editable mode) if you plan to develop *pygama*. If you
-do not have admin rights you can install pygama as a normal user:
-
-.. code-block:: console
-
-    $ pip install pygama --user
-
-and make sure that your ``PYTHONPATH`` environment variable is aware of the
-install location. Optionally-required dependencies can be also collected:
-
-.. code-block:: console
-
-    $ pip install pygama[daq]  # if you need to convert DAQ files to LH5
-    $ pip install pygama[test] # if you want to run package test files
-    $ pip install pygama[docs] # if you need to build documentation
-
-To uninstall pygama run:
-
-.. code-block:: console
-
-    $ pip uninstall pygama
diff --git a/docs/source/manuals/build_dsp.rst b/docs/source/manuals/build_dsp.rst
index 8830594b7..635ec0ee0 100644
--- a/docs/source/manuals/build_dsp.rst
+++ b/docs/source/manuals/build_dsp.rst
@@ -1,7 +1,38 @@
 Digital Signal Processing
 =========================
 
-*Under construction...*
+Global Numba options
+--------------------
+
+Pygama offers the possibility to change the value of some default Numba options
+either using environment variables or at runtime. One typical use case is to enable `caching the result of compilation
+<https://numba.readthedocs.io/en/stable/user/jit.html?#cache>`_,
+which significantly reduces loading times. Numba options globally set by pygama
+are defined as attributes of the :class:`~.dsp.utils.NumbaDefaults` class. Have
+a look to the documentation for :func:`numba.jit` and :func:`numba.guvectorize`
+to learn about their meaning.
+
+.. note::
+   Some special processors override default option values.
+
+The environment variables that can be set are:
+
+:``PYGAMA_CACHE``: Set caching behavior (default false)
+:``PYGAMA_BOUNDSCHECK``: Set automatic bounds checking (default false)
+
+Here's an example of how global option customization can achieved in user
+scripts:
+
+.. code-block:: python
+
+    from pygama.dsp.utils import numba_defaults
+    from pygama.dsp import build_dsp
+
+    # must set options before explicitly importing pygama.dsp.processors!
+    numba_defaults.cache = True
+
+    # processors imports happen here, if not explicitly done before
+    build_dsp(...)
 
 Command line interface
 ----------------------
@@ -45,7 +76,7 @@ Writing custom processors
     from numba import guvectorize
 
     from pygama.dsp.errors import DSPFatal
-
+    from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
     # 2) Provide instructions to Numba
     #
@@ -53,14 +84,17 @@ Writing custom processors
     # https://numba.pydata.org/numba-doc/latest/user/vectorize.html#the-guvectorize-decorator
     #
     # Notes:
-    # - Use "nopython=True, cache=True"
+    # - Set default Numba arguments by expanding dsp.utils.numba_defaults_kwargs (see below)
+    # - If you need to customize the value of one default argument do it, and
+    #   use numba_defaults from dsp.utils to set up the remaining arguments:
+    #       @guvectorize(..., cache=numba_defaults.cache, boundscheck=True)
     # - Use two declarations, one for 32-bit variables and one for 64-bit variables
     # - Do not use "int" as it does not support an NaN value
     # - Use [:] for all output parameters
     #
     @guvectorize(["void(float32[:], float32, float32, float32[:])",
                   "void(float64[:], float64, float64, float64[:])"],
-                  "(n),(),()->()", nopython=True, cache=True)
+                  "(n),(),()->()", **nb_kwargs)
 
     # 3) Define the processor interface
     #
diff --git a/docs/source/notebooks/IntroToDSP.ipynb b/docs/source/notebooks/IntroToDSP.ipynb
new file mode 100644
index 000000000..86bb12517
--- /dev/null
+++ b/docs/source/notebooks/IntroToDSP.ipynb
@@ -0,0 +1,551 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Introduction to Digital Signal Processing\n",
+    "\n",
+    "This notebook will provide a brief introduction to a few of LEGEND's bread and butter digital signal processing filters. These processors will be applied in sequence to digitized waveforms that are recorded to disk, and a brief explanation of why we use them is provided. The parameters of each processor may be varied as well in order to see the affects that they have.\n",
+    "\n",
+    "Recommended reading is *Radiation Detection and Measurement* by Glenn F. Knoll, Chapter 17, which introduces many pulse-shaping concepts (although, from a hardware-perspective more than a digital signal processing perspective; even so, the concepts are similar).\n",
+    "\n",
+    "### Set up the Python environment\n",
+    "\n",
+    "It is recommended that you use a file from the [LEGEND test-data repository](https://github.com/legend-exp/legend-testdata); you may need to change `data_file` if you want to run this notebook yourself. We use the [Python wrapper of the test-data repository](https://github.com/legend-exp/legend-testdata-py)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pygama.lgdo import LH5Store, ls, show, load_dfs\n",
+    "from pygama.vis import WaveformBrowser\n",
+    "from pygama.math import units\n",
+    "from pygama.dsp import build_dsp\n",
+    "\n",
+    "import matplotlib.pyplot as plt\n",
+    "import os\n",
+    "from legend_testdata import LegendTestData\n",
+    "\n",
+    "ldata = LegendTestData()\n",
+    "data_file = ldata.get_path(\"lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5\")\n",
+    "entry_no = 2  # which waveform in our example data_file to use for plotting\n",
+    "\n",
+    "plt.rcParams[\"figure.figsize\"] = (14, 4)\n",
+    "plt.rcParams[\"figure.facecolor\"] = \"white\"\n",
+    "plt.rcParams[\"font.size\"] = 12"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Processor Setup\n",
+    "\n",
+    "This next panel defines all of the processors that we are going to run. Normally this is done in a JSON file in almost the exact same format shown here [[here's a detailed specification]](https://pygama.readthedocs.io/en/stable/api/pygama.dsp.html#pygama.dsp.processing_chain.build_processing_chain). You are encouraged to try changing some of the values in these processors to see the effect on the analysis."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dsp_config = {\n",
+    "    \"outputs\": [\n",
+    "        \"timestamp\",\n",
+    "        \"channel\",\n",
+    "        \"trapEmax\",\n",
+    "        \"trapTmax\",\n",
+    "        \"tp_0\",\n",
+    "        \"ftp\",\n",
+    "        \"trapEftp\",\n",
+    "        \"bl\",\n",
+    "        \"bl_sig\",\n",
+    "        \"A_gaus\",\n",
+    "    ],\n",
+    "    \"processors\": {\n",
+    "        \"max_index\": {\n",
+    "            \"function\": \"argmax\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"waveform\", 1, \"max_index\"],\n",
+    "            \"kwargs\": {\"signature\": \"(n),()->()\", \"types\": [\"fi->i\"]},\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"bl, bl_sig, slope, intercept\": {\n",
+    "            \"function\": \"linear_slope_fit\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"waveform[:1650]\", \"bl\", \"bl_sig\", \"slope\", \"intercept\"],\n",
+    "            \"unit\": [\"ADC\", \"ADC\", \"ADC\", \"ADC\"],\n",
+    "        },\n",
+    "        \"wf_blsub\": {\n",
+    "            \"function\": \"subtract\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"waveform\", \"bl\", \"wf_blsub\"],\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"wf_pz\": {\n",
+    "            \"function\": \"pole_zero\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_blsub\", \"180*us\", \"wf_pz\"],\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"wf_trap\": {\n",
+    "            \"function\": \"trap_norm\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_pz\", \"8*us\", \"2*us\", \"wf_trap\"],\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"trapEmax\": {\n",
+    "            \"function\": \"amax\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"wf_trap\", 1, \"trapEmax\"],\n",
+    "            \"kwargs\": {\"signature\": \"(n),()->()\", \"types\": [\"fi->f\"]},\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"trapTmax\": {\n",
+    "            \"function\": \"argmax\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"wf_trap\", 1, \"trapTmax\"],\n",
+    "            \"kwargs\": {\"signature\": \"(n),()->()\", \"types\": [\"fi->i\"]},\n",
+    "            \"unit\": \"ns\",\n",
+    "        },\n",
+    "        \"wf_atrap\": {\n",
+    "            \"function\": \"asym_trap_filter\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\n",
+    "                \"wf_pz\",\n",
+    "                \"0.1*us\",\n",
+    "                \"1*us\",\n",
+    "                \"3*us\",\n",
+    "                \"wf_atrap\",\n",
+    "            ],  # rising edge, flat section, falling edge\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"tmax\": {\n",
+    "            \"function\": \"argmax\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"wf_atrap\", 1, \"tmax\"],\n",
+    "            \"kwargs\": {\"signature\": \"(n),()->()\", \"types\": [\"fi->i\"]},\n",
+    "            \"unit\": \"ns\",\n",
+    "        },\n",
+    "        \"tp_0\": {\n",
+    "            \"function\": \"time_point_thresh\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_atrap\", 0, \"tmax\", 0, \"tp_0\"],\n",
+    "            \"unit\": \"ns\",\n",
+    "        },\n",
+    "        \"ftp\": {\n",
+    "            \"function\": \"add\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"tp_0\", \"2*us+8*us-256*ns\", \"ftp\"],\n",
+    "            \"unit\": \"ns\",\n",
+    "        },\n",
+    "        \"trapEftp\": {\n",
+    "            \"function\": \"fixed_time_pickoff\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_trap\", \"ftp\", \"105\", \"trapEftp\"],  # ord('i') == 105\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"curr\": {\n",
+    "            \"function\": \"avg_current\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_pz\", 1, \"curr(len(wf_pz)-1, 'f', period=waveform.period)\"],\n",
+    "            \"unit\": \"ADC/sample\",\n",
+    "        },\n",
+    "        \"curr_gaus\": {\n",
+    "            \"function\": \"gaussian_filter1d\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"curr\", \"curr_gaus(len(curr), 'f', period=waveform.period)\"],\n",
+    "            \"init_args\": [\"50\", \"4\"],\n",
+    "            \"unit\": \"ADC/sample\",\n",
+    "        },\n",
+    "        \"A_gaus\": {\n",
+    "            \"function\": \"amax\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"curr_gaus\", 1, \"A_gaus\"],\n",
+    "            \"kwargs\": {\"signature\": \"(n),()->()\", \"types\": [\"fi->f\"]},\n",
+    "            \"unit\": \"ADC/sample\",\n",
+    "        },\n",
+    "    },\n",
+    "}"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Plotting Processors via `WaveformBrowser`\n",
+    "\n",
+    "In the next cells, we will use the `WaveformBrowser` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.vis.html#pygama.vis.waveform_browser.WaveformBrowser) function to visualize the various processors that are calculated in the config file. See the [WaveformBrowser tutorial](https://pygama.readthedocs.io/en/stable/notebooks/WaveformBrowser.html) for more info.\n",
+    "\n",
+    "#### Raw Waveform\n",
+    "This is a waveform as produced by a flashcam digitizer. The waveform can be divided into three regions; first, the baseline, the flat part up to ~45000 ns. Next the rising edge, where it increases at ~45000 ns. Lastly, the falling tail, which is an approximately exponential return to baseline that begins after ~45000 ns.\n",
+    "\n",
+    "Note that, in the below, we do not specify the `lines` keyword argument, as is done in subsequent cells. The default behavior in this case is to plot the raw waveform."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    data_file,\n",
+    "    \"geds/raw\",\n",
+    "    styles=[\n",
+    "        {\"color\": [\"skyblue\"], \"ls\": [\"dotted\"]},\n",
+    "    ],\n",
+    ")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "#### Baseline Subtraction\n",
+    "The first processors applied perform baseline subtraction. First we measure the baseline using the first 1650 samples of the waveform, and then we subtract the baseline from each sample."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    data_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=dsp_config,\n",
+    "    lines=[\"waveform\", \"wf_blsub\"],\n",
+    "    styles=[\n",
+    "        {\"color\": [\"skyblue\"], \"ls\": [\"dotted\"]},\n",
+    "        {\"color\": [\"r\"]},\n",
+    "    ],\n",
+    ")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "#### Pole-Zero Correction\n",
+    "The next step is to correct our waveform for the electronics response, which produces the exponential shape characteristic of our waveforms. The goal of this transform is to return a waveform with a perfectly flat tail. The pole-zero correction assumes a simple electronics response characterized by a single RC constant (in this case 180 microseconds). In reality, our electronics can be characterized by multiple RC constants; as a result we do not achieve a perfectly flat top, but by applying more complex transforms at this step we can improve things. The pole-zero applied here is close enough for most applications, though.\n",
+    "\n",
+    "Knoll chapter 17 sec. 7B gives a more complete description."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    data_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=dsp_config,\n",
+    "    lines=[\"wf_blsub\", \"wf_pz\"],\n",
+    "    styles=[\n",
+    "        {\"color\": [\"skyblue\"], \"ls\": [\":\"]},\n",
+    "        {\"color\": [\"r\"]},\n",
+    "    ],\n",
+    "    x_lim=(\"40*us\", \"70*us\"),\n",
+    ")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "#### Trapezoidal Filter\n",
+    "The trapezoidal filter is a nearly optimal filter that is used to extract pulse amplitude for a variety of applications. The trapezoid is the difference, at each point, between the average over two sets of samples separated by a short time. It can be characterized by:\n",
+    "\n",
+    "- **Integration time**: number of samples averaged, and the length of the rising and falling edges of the trapezoid. We use 8 microseconds here. A longer integration time will reduce the effect of noise on our energy measurement (up to a limit that depends on low frequency noise; it also can't be too long for the number of samples collected).\n",
+    "- **Flat top time**: the separation between the integration regions, and the length of the flat top of the trapezoid. This should be longer than the rising edge of the waveform; if it is not, the top will not be totally flat and we will measure a reduction in the charge called \"ballistic deficit.\" We also don't want the flat top to be too long, or else low frequency noise will degrade our resolution. Here, we use 2 us as our flat top time.\n",
+    "\n",
+    "Knoll Chapter 17 Sec. 7 has a description of trapezoidal shaping."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    data_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=dsp_config,\n",
+    "    lines=[\"wf_pz\", \"wf_trap\"],\n",
+    "    styles=[\n",
+    "        {\"color\": [\"skyblue\"], \"ls\": [\":\"]},\n",
+    "        {\"color\": [\"r\"]},\n",
+    "    ],\n",
+    "    x_lim=(\"40*us\", \"70*us\"),\n",
+    ")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "#### Energy estimation: trap-max\n",
+    "The most common way to measure uncalibrated energy is to use the maximum of the trapezoidal filter. This works fairly well, assuming you have a nice flat top; however, due to our imperfect pole-zero correction, this can result in a degradation in our energy resolution."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    data_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=dsp_config,\n",
+    "    lines=[\"wf_trap\", \"trapEmax\", \"trapTmax\"],\n",
+    "    styles=[\n",
+    "        {\"color\": [\"r\"]},\n",
+    "        {\"color\": [\"b\"]},\n",
+    "        {\"color\": [\"b\"]},\n",
+    "    ],\n",
+    "    x_lim=(\"40*us\", \"70*us\"),\n",
+    ")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "#### Energy estimation: fixed time pick-off\n",
+    "Another way to measure energy is to pick-off the amplitude of the trapezoidal filter at a fixed time relative to the start of the waveform. The fixed time should fall on the flat top of the waveform, with enough padding to avoid ballistic deficit, and to prevent noise in the start time from pushing the pick-off time onto the falling edge of the waveform. We choose a pickoff time 0.25 microseconds before the falling edge begins. This technique also requires an accurate measurement of the start time ($t_0$) of the waveform (see next section)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    data_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=dsp_config,\n",
+    "    lines=[\"wf_trap\", \"tp_0\", \"ftp\", \"trapEftp\"],\n",
+    "    styles=[\n",
+    "        {\"color\": [\"r\"]},\n",
+    "        {\"color\": [\"b\"]},\n",
+    "        {\"color\": [\"g\"]},\n",
+    "        {\"color\": [\"g\"]},\n",
+    "    ],\n",
+    "    x_lim=(\"40*us\", \"70*us\"),\n",
+    ")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "#### Start time measurement\n",
+    "The start time ($t_0$) of the waveform must be measured in order to apply the fixed-time pickoff. We use the following technique to measure this:\n",
+    "\n",
+    "1. Apply an asymmetric trapezoidal filter, which uses a short integration time for the rising edge and a long integration time for the falling tail. The short rising edge has the advantage of preserving most timing information in the rising edge. The longer tail helps us correct for low frequency noise. Here, we use 0.1 / 1 / 3 microseconds as the rise / flat / fall times.\n",
+    "\n",
+    "2. Find the maximum of the asymmetric-trap, and walk back from there until we cross zero. The first sample that we cross this threshold is our $t_0$.\n",
+    "\n",
+    "An accurate $t_0$ is important both for the fixed-time pickoff, and for various drift-time corrections (such as for charge-trapping) that are not described in this notebook. While this filter is mostly reliable, it can occasionally fail for low signal-to-noise waveforms, or waveforms with large transiant blips in the noise."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    data_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=dsp_config,\n",
+    "    lines=[\"wf_pz\", \"wf_atrap\", \"tp_0\"],\n",
+    "    styles=[\n",
+    "        {\"color\": [\"skyblue\"], \"ls\": [\":\"]},\n",
+    "        {\"color\": [\"r\"]},\n",
+    "        {\"color\": [\"g\"]},\n",
+    "    ],\n",
+    "    x_lim=(\"40*us\", \"70*us\"),\n",
+    ")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "#### Current measurement\n",
+    "Current is needed to measure $A/E$. This can be found using a simple derivative filter. However, the signal-to-noise using just the derivative can be quite high, so we often apply additional smoothing filters after (see next section)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    data_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=dsp_config,\n",
+    "    lines=[\n",
+    "        \"wf_pz\",\n",
+    "        \"curr\",\n",
+    "    ],\n",
+    "    styles=[\n",
+    "        {\"color\": [\"skyblue\"], \"ls\": [\":\"]},\n",
+    "        {\"color\": [\"r\"]},\n",
+    "    ],\n",
+    "    x_lim=(\"40*us\", \"70*us\"),\n",
+    ")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "#### Gaussian-smoothed current\n",
+    "One way we can improve the signal-to-noise ratio is by applying a gaussian filter. This is a convolution of our waveform with a gaussian kernel. While this reduces the impact of noise on our measurement, it also reduces the timing information in the rising edge, reducing the signal; as a result, we must find an optimal balance in our smoothing. Knoll chapter 17 Sec. 4 describes gaussian shaping.\n",
+    "\n",
+    "The current amplitude ($A$) is the maximum of the smoothed current waveform."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    data_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=dsp_config,\n",
+    "    lines=[\"curr\", \"curr_gaus\", \"A_gaus\"],\n",
+    "    styles=[{\"color\": [\"skyblue\"], \"ls\": [\":\"]}, {\"color\": [\"r\"]}, {\"color\": [\"g\"]}],\n",
+    "    x_lim=(\"40*us\", \"70*us\"),\n",
+    ")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Using `build_dsp()`\n",
+    "\n",
+    "At this point, we have used the `WaveformBrowser` to view the outputs of the various DSP calculations that were specified in our example `dsp_config` from earlier. However, we have not actually saved these outputs to any file. In `pygama`, this is done via the `build_dsp()` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.dsp.html#pygama.dsp.build_dsp.build_dsp) function, which takes our raw example file, runs the processing chain, and saves the outputs to a new LH5 file, which we will simply name `example_dsp_file.lh5`.\n",
+    "\n",
+    "Let's build the DSP!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "build_dsp(\n",
+    "    data_file,\n",
+    "    \"./example_dsp_file.lh5\",\n",
+    "    dsp_config=dsp_config,\n",
+    "    write_mode=\"r\",  # used to overwrite an existing file\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We can now take a look at the contents of the new file, using either `ls()` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.ls) or `show()` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.show) from `pygama.lgdo` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html). Working with LH5 files is discussed in further detail in the [LH5Files](https://pygama.readthedocs.io/en/stable/notebooks/LH5Files.html) tutorial, and we direct the reader there for more information."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ls(\"example_dsp_file.lh5\")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "show(\"example_dsp_file.lh5\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We see that we have indeed saved our output values in `dsp_config` to this new file. Lastly, let's access them to see the actual values, again as similarly done in the [LH5Files](https://pygama.readthedocs.io/en/stable/notebooks/LH5Files.html) tutorial."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "store = LH5Store()\n",
+    "obj, nrows = store.read_object(\"geds/dsp\", \"./example_dsp_file.lh5\")\n",
+    "print(\n",
+    "    f\"We have saved the following list of outputs from our DSP routine: {list(obj.keys())}\"\n",
+    ")\n",
+    "print(\n",
+    "    f\"There are {nrows} rows of values, corresponding to that same number of waveforms.\"\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We see that the various outputs are in this file, and they have been calculated for 100 waveforms.\n",
+    "\n",
+    "To manipulate the various values, it is recommended to convert to a Pandas DataFrame, via the provided `load_dfs` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.load_dfs) function, as show below. We load all of the outputs of the processors to a single DataFrame, specified by inputting the `'geds/dsp'` group in the LH5 file."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "load_dfs(\"./example_dsp_file.lh5\", obj.keys(), \"geds/dsp\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "And from here, the user can plot various values, manipulate them in some way, or anything else. "
+   ]
+  }
+ ],
+ "metadata": {},
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/docs/source/notebooks/LH5Files.ipynb b/docs/source/notebooks/LH5Files.ipynb
new file mode 100644
index 000000000..8e8740a2b
--- /dev/null
+++ b/docs/source/notebooks/LH5Files.ipynb
@@ -0,0 +1,328 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "28a2ded5",
+   "metadata": {},
+   "source": [
+    "# Handling LH5 data\n",
+    "\n",
+    "LEGEND stores its data in [HDF5](https://www.hdfgroup.org/solutions/hdf5) format, a high-performance data format becoming popular in experimental physics. LEGEND Data Objects (LGDO) are represented as HDF5 objects according to a custom specification, documented [here](https://legend-exp.github.io/legend-data-format-specs/dev/hdf5)."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "701caca4",
+   "metadata": {},
+   "source": [
+    "## Reading data from disk\n",
+    "\n",
+    "Let's start by downloading a small test LH5 file with the [legend-testdata](https://pypi.org/project/legend-testdata/) package (it takes a while depending on your internet connection):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ce298181",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from legend_testdata import LegendTestData\n",
+    "\n",
+    "ldata = LegendTestData()\n",
+    "lh5_file = ldata.get_path(\"lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c136b537",
+   "metadata": {},
+   "source": [
+    "We can use `pygama.lgdo.lh5_store.ls()` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.ls) to inspect the file contents:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "eb629856",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pygama.lgdo import ls\n",
+    "\n",
+    "ls(lh5_file)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "890676b6",
+   "metadata": {},
+   "source": [
+    "This particular file contains an HDF5 group (they behave like directories). The second argument of `ls()` can be used to inspect a group (without the trailing `/`, only the group name is returned, if existing):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "22218548",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ls(lh5_file, \"geds/\")  # returns ['geds/raw'], which is a group again\n",
+    "ls(lh5_file, \"geds/raw/\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "4fd29157",
+   "metadata": {},
+   "source": [
+    "<div class=\"alert alert-info\">\n",
+    "\n",
+    "**Note:** Alternatively to `ls()`, `show()` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.show) prints a nice representation of the LH5 file contents (with LGDO types) on screen:\n",
+    "\n",
+    "</div>"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a382fcbd",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pygama.lgdo import show\n",
+    "\n",
+    "show(lh5_file)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ef09f43c",
+   "metadata": {},
+   "source": [
+    "The group contains several LGDOs. Let's read them in memory. We start by initializing an `LH5Store` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.LH5Store) object:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "0f4e2525",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pygama.lgdo import LH5Store\n",
+    "\n",
+    "store = LH5Store()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "60ce5b61",
+   "metadata": {},
+   "source": [
+    "`read_object()` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.LH5Store.read_object) reads an LGDO from disk and returns the object in memory together with the number of rows (as a tuple), if an object has such a property. Let's try to read `geds/raw`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5a8b9ca4",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "store.read_object(\"geds/raw\", lh5_file)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "9efdb924",
+   "metadata": {},
+   "source": [
+    "As shown by the type signature, it is interpreted as a `Table` with 100 rows. Its contents (or \"columns\") can be therefore viewed as LGDO objects of the same length. For example `timestamp`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "da22855d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "obj, n_rows = store.read_object(\"geds/raw/timestamp\", lh5_file)\n",
+    "obj"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "f2c8ff34",
+   "metadata": {},
+   "source": [
+    "is an LGDO `Array` with 100 elements.\n",
+    "\n",
+    "`read_object()` also allows to perform more advanced data reading. For example, let's read only rows from 15 to 25:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ce246705",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "obj, n_rows = store.read_object(\"geds/raw/timestamp\", lh5_file, start_row=15, n_rows=10)\n",
+    "print(obj)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "195ebefd",
+   "metadata": {},
+   "source": [
+    "Or, let's read only columns `timestamp` and `energy` from the `geds/raw` table and rows `[1, 3, 7, 9, 10, 15]`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "eaa06ebe",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "obj, n_rows = store.read_object(\n",
+    "    \"geds/raw\", lh5_file, field_mask=(\"timestamp\", \"energy\"), idx=[1, 3, 7, 9, 10, 15]\n",
+    ")\n",
+    "print(obj)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b3f52d77",
+   "metadata": {},
+   "source": [
+    "As you might have noticed, `read_object()` loads all the requested data in memory at once. This can be a problem when dealing with large datasets. `LH5Iterator` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.LH5Iterator) makes it possible to handle data one chunk at a time (sequentially) to avoid running out of memory:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "8f24d4e3",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pygama.lgdo import LH5Iterator\n",
+    "\n",
+    "for lh5_obj, entry, n_rows in LH5Iterator(lh5_file, \"geds/raw/energy\", buffer_len=20):\n",
+    "    print(f\"entry {entry}, energy = {lh5_obj} ({n_rows} rows)\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "3ab3794c",
+   "metadata": {},
+   "source": [
+    "## Writing data to disk\n",
+    "\n",
+    "Let's start by creating some LGDOs:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "aaf2d352",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pygama.lgdo import Array, Scalar, WaveformTable\n",
+    "import numpy as np\n",
+    "\n",
+    "rng = np.random.default_rng(12345)\n",
+    "\n",
+    "scalar = Scalar(\"made with pygama!\")\n",
+    "array = Array(rng.random(size=10))\n",
+    "wf_table = WaveformTable(values=rng.integers(low=1000, high=5000, size=(10, 1000)))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "cbfd91c2",
+   "metadata": {},
+   "source": [
+    "The `write_object()` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.LH5Store.write_object) method of `LH5Store` makes it possible to write LGDO objects on disk. Let's start by writing `scalar` with name `message` in a file named `my_data.lh5` in the current directory:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "5ad1d10e",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "store = LH5Store()\n",
+    "\n",
+    "store.write_object(\n",
+    "    scalar, name=\"message\", lh5_file=\"my_objects.lh5\", wo_mode=\"overwrite_file\"\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "430c2c7e",
+   "metadata": {},
+   "source": [
+    "Let's now inspect the file contents:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "777d7daf",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pygama.lgdo import show\n",
+    "\n",
+    "show(\"my_objects.lh5\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "227bea86",
+   "metadata": {},
+   "source": [
+    "The string object has been written at the root of the file `/`. Let's now write also `array` and `wf_table`, this time in a HDF5 group called `closet`:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "19c7703c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "store.write_object(array, name=\"numbers\", group=\"closet\", lh5_file=\"my_objects.lh5\")\n",
+    "store.write_object(\n",
+    "    wf_table, name=\"waveforms\", group=\"closet\", lh5_file=\"my_objects.lh5\"\n",
+    ")\n",
+    "show(\"my_objects.lh5\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e266c2ac",
+   "metadata": {},
+   "source": [
+    "Everything looks right!\n",
+    "\n",
+    "<div class=\"alert alert-info\">\n",
+    "\n",
+    "**Note:** `write_objects()` allows for more advanced usage, like writing only some rows of the input object or appending to existing array-like structures. Have a look at the [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.lgdo.html#pygama.lgdo.lh5_store.LH5Store.write_object) for more information.\n",
+    "\n",
+    "</div>"
+   ]
+  }
+ ],
+ "metadata": {},
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/source/notebooks/WaveformBrowser.ipynb b/docs/source/notebooks/WaveformBrowser.ipynb
new file mode 100644
index 000000000..b5915ec8e
--- /dev/null
+++ b/docs/source/notebooks/WaveformBrowser.ipynb
@@ -0,0 +1,430 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Browsing waveforms with the `WaveformBrowser`\n",
+    "\n",
+    "This is a tutorial demonstrating several ways to use the `WaveformBrowser` to examine waveform data. This will consist of multiple examples, increasing in complexity, and will use LEGEND test data from [legend-testdata](https://github.com/legend-exp/legend-testdata). The `WaveformBrowser` [[docs]](https://pygama.readthedocs.io/en/stable/api/pygama.vis.html#pygama.vis.waveform_browser.WaveformBrowser) is a pygama utility for accessing waveforms from raw files in an interactive way, enabling you to access, draw, or even process waveforms. Some use cases for this utility include investigating a population of waveforms, and debugging waveform processors.\n",
+    "\n",
+    "Why do we need a waveform browser when we can access data via Pandas dataframes? Pandas dataframes work extremely well for reading tables of simple values from multiple HDF5 files. However, they are less optimal for waveforms. The reason for this is that they require holding all waveforms in memory at once. If we want to look at waveforms spread out across multiple files, this can potentially take up GBs of memory, which will cause problems! To get around this, we want to load only bits of the files into memory at a time and pull out only what we need. Since this is an inconvenient process, the `WaveformBrowser` will do this for you, while hiding the details as much as possible.\n",
+    "\n",
+    "Let's start by importing necessary modules and test data:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib inline\n",
+    "\n",
+    "import matplotlib.pyplot as plt\n",
+    "import pandas as pd\n",
+    "import numpy as np\n",
+    "import os, json\n",
+    "import pint\n",
+    "\n",
+    "u = pint.get_application_registry()\n",
+    "\n",
+    "import pygama.lgdo.lh5_store as lh5\n",
+    "from pygama.vis.waveform_browser import WaveformBrowser\n",
+    "from legend_testdata import LegendTestData\n",
+    "\n",
+    "ldata = LegendTestData()\n",
+    "raw_file = ldata.get_path(\"lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5\")\n",
+    "\n",
+    "plt.rcParams[\"figure.figsize\"] = (14, 4)\n",
+    "plt.rcParams[\"figure.facecolor\"] = \"white\"\n",
+    "plt.rcParams[\"font.size\"] = 14"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Basic browsing\n",
+    "\n",
+    "First, a minimal example simply drawing waveforms from the raw file. Let's create a minimal browser and draw the 50th waveform:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(raw_file, \"geds/raw\")\n",
+    "browser.draw_entry(50)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To draw multiple waveforms in a single figure, provide a list if indices:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser.draw_entry([64, 82, 94])"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Now draw the next waveform in the file. You can run this cell multiple times to scroll through many waveforms:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser.draw_next();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Filtering waveforms\n",
+    "\n",
+    "Ok, that was nice, but how often do we just want to scroll through all of our waveforms?\n",
+    "\n",
+    "For our next example, we will select a population of waveforms from within the files, and draw multiple at once. Selecting a population of events to draw uses the same syntax as NumPy and Pandas, and can be done either with a list of entries or a boolean NumPy array. This selection can be made using data from a DSP file (or higher tiers).\n",
+    "\n",
+    "We will also learn how to set a few other properties of the figure.\n",
+    "\n",
+    "Let's quickly produce a DSP file (see tutorial on running DSP):"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pygama.dsp import build_dsp\n",
+    "\n",
+    "dsp_file = \"LDQTA_r117_20200110T105115Z_cal_geds_dsp.lh5\"\n",
+    "build_dsp(raw_file, dsp_file, \"metadata/dsp-config.json\", write_mode=\"r\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Now, we load a Pandas DataFrame from the file that we can use to make our selection:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pygama.lgdo import load_dfs\n",
+    "\n",
+    "df = load_dfs(dsp_file, [\"trapEmax\", \"AoE\"], \"geds/dsp\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We use Pandas' querying syntax to create a selection mask around high energy events:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "energy = df[\"trapEmax\"]\n",
+    "energy_selection = (energy > 10000) & (energy < 30000)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Let's have a look at them:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "energy.hist(bins=200, range=(0, 30000))\n",
+    "energy[energy_selection].hist(bins=200, range=(0, 30000))\n",
+    "plt.xlabel(\"energy [a.u.]\");"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We then construct a `WaveformBrowser` with this cut:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    raw_file,\n",
+    "    \"geds/raw\",\n",
+    "    aux_values=df,\n",
+    "    legend=\"E = {trapEmax}\",  # values to put in the legend\n",
+    "    x_lim=(40 * u.us, 50 * u.us),  # range for time-axis\n",
+    "    entry_mask=energy_selection,  # apply cut\n",
+    "    n_drawn=5,  # number to draw for draw_next\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "And finally draw the next 5 batches of 10 waveforms:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "for entries, i in zip(browser, range(2)):\n",
+    "    browser.new_figure()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "If you can use interactive plots, you can replace `browser.new_figure()` with e.g. `plt.pause(1)` to draw a slideshow!"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Visualizing waveform transforms\n",
+    "\n",
+    "Now, we'll shift from drawing populations of waveforms to drawing waveform transforms. We can draw any waveforms that are defined in a DSP JSON configuration file. This is useful for debugging purposes and for developing processors. We will draw the baseline subtracted waveform, pole-zero corrected waveform, and trapezoidal filter waveform. We will also draw horizontal and vertical lines for `trapE` (the maximum of the trapezoid) and `tp_0` (our estimate of the start of the waveform's rise). The browser will determine whether these lines should be horizontal or vertical based on the unit."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    raw_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=\"metadata/dsp-config.json\",  # Need to include a dsp config file!\n",
+    "    database={\"pz_const\": \"180*us\"},\n",
+    "    lines=[\n",
+    "        \"wf_blsub\",\n",
+    "        \"wf_pz\",\n",
+    "        \"wf_trap\",\n",
+    "        \"trapEmax\",\n",
+    "        \"tp_0\",\n",
+    "    ],  # names of waveforms from dsp config file\n",
+    "    styles=[\n",
+    "        {\"ls\": [\"-\"], \"c\": [\"orange\"]},\n",
+    "        {\"ls\": [\":\"], \"c\": [\"green\"]},\n",
+    "        {\"ls\": [\"--\"], \"c\": [\"blue\"]},\n",
+    "        {\"lw\": [0.5], \"c\": [\"black\"]},\n",
+    "        {\"lw\": [0.5], \"c\": [\"red\"]},\n",
+    "    ],\n",
+    "    legend=[\n",
+    "        \"Waveform\",\n",
+    "        \"PZ Corrected\",\n",
+    "        \"Trap Filter\",\n",
+    "        \"Trap Max = {trapEmax}\",\n",
+    "        \"t0 = {tp_0}\",\n",
+    "    ],\n",
+    "    legend_opts={\"loc\": \"upper left\"},\n",
+    "    x_lim=(\"35*us\", \"75*us\"),  # x axis range\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser.draw_next();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Comparing waveforms\n",
+    "\n",
+    "Here's a more advanced example that combines the previous two. We will draw waveforms from multiple populations for the sake of comparison. This will require creating two separate browsers and drawing them onto the same axes. We'll also normalize and baseline-subtract the waveforms from parameters in a DSP file. Finally, we'll add some formatting options to the lines and legend.\n",
+    "\n",
+    "We start by selecting two sub-populations from the high-energy events from above by cutting on A/E:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "aoe = df[\"AoE\"]\n",
+    "aoe_cut = (aoe < 0.045) & energy_selection\n",
+    "aoe_accept = (aoe > 0.045) & energy_selection\n",
+    "\n",
+    "aoe[aoe_accept].hist(bins=200, range=(0, 0.1))\n",
+    "aoe[aoe_cut].hist(bins=200, range=(0, 0.1))\n",
+    "plt.xlabel(\"A/E [a.u.]\");"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Now, we create two distinct browsers:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser1 = WaveformBrowser(\n",
+    "    raw_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=\"metadata/dsp-config.json\",\n",
+    "    lines=\"wf_blsub\",  # draw baseline subtracted waveform instead of the original\n",
+    "    norm=\"trapEmax\",  # normalize waveforms based on amplitude\n",
+    "    styles={\"color\": [\"red\", \"orange\"]},  # set a color cycle for this\n",
+    "    legend=\"E = {trapEmax} ADC, A/E = {AoE:~.3f}\",  # formatted values to put in the legend\n",
+    "    x_lim=(40 * u.us, 50 * u.us),\n",
+    "    entry_mask=aoe_cut,  # apply the first cut\n",
+    "    n_drawn=2,\n",
+    ")\n",
+    "browser2 = WaveformBrowser(\n",
+    "    raw_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=\"metadata/dsp-config.json\",\n",
+    "    lines=\"wf_blsub\",\n",
+    "    norm=\"trapEmax\",\n",
+    "    styles={\"color\": [\"blue\", \"cyan\"]},\n",
+    "    legend=\"E = {trapEmax} ADC, A/E = {AoE:~.3f}\",\n",
+    "    legend_opts={\n",
+    "        \"loc\": \"lower right\",\n",
+    "        \"bbox_to_anchor\": (1, 0),\n",
+    "    },  # set options for drawing the legend\n",
+    "    x_lim=(40 * u.us, 50 * u.us),\n",
+    "    entry_mask=aoe_accept,  # apply the other cut\n",
+    "    n_drawn=2,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "And draw!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser1.draw_next()\n",
+    "browser2.set_figure(browser1)  # use the same figure/axis as the other browser\n",
+    "browser2.draw_next(clear=False);  # Set clear to false to draw on the same axis!"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Direct access of waveforms and other quantities\n",
+    "\n",
+    "The waveforms, lines and legend entries are all stored inside of the waveform browser. Sometimes you want to access these directly; maybe you want to access the raw data, or do control the lines in a way not enabled by the `WaveformBrowser` interface. It is possible to access them quickly and easily.\n",
+    "\n",
+    "When accessing waveforms in this way, you can also do the same things previously shown, such as applying a data cut and grabbing processed waveforms. For this example, we are going to get waveforms, trap-waveforms and trap energies, after applying an A/E cut.\n",
+    "\n",
+    "Let's start by defining a browser object:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(\n",
+    "    raw_file,\n",
+    "    \"geds/raw\",\n",
+    "    dsp_config=\"metadata/dsp-config.json\",\n",
+    "    database={\"pz_const\": \"180*us\"},\n",
+    "    lines=[\"waveform\", \"wf_trap\"],\n",
+    "    legend=[\"E = {trapEmax}\"],\n",
+    "    entry_mask=aoe_accept,\n",
+    "    n_drawn=2,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Waveforms and legend values are stored as a dictionary from the parameter name to a list of stored values:\n",
+    "\n",
+    "- The waveforms are as a list of matplotlib `Line2D` artists\n",
+    "- Horizontal and vertical lines are also stored as `Line2D` artists\n",
+    "- Legend entries are stored as pint `Quantities`\n",
+    "\n",
+    "Now, let's simply print them:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser.find_next()\n",
+    "waveforms = browser.lines[\"waveform\"]\n",
+    "traps = browser.lines[\"wf_trap\"]\n",
+    "energies = browser.legend_vals[\"trapEmax\"]\n",
+    "for wf, trap, en in zip(waveforms, traps, energies):\n",
+    "    print(\"Raw waveform:\", wf.get_ydata())\n",
+    "    print(\"Trap-filtered waveform:\", trap.get_ydata())\n",
+    "    print(\"TrapEmax:\", en)\n",
+    "    print()"
+   ]
+  }
+ ],
+ "metadata": {},
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/docs/source/notebooks/metadata/dsp-config.json b/docs/source/notebooks/metadata/dsp-config.json
new file mode 100644
index 000000000..64078dbca
--- /dev/null
+++ b/docs/source/notebooks/metadata/dsp-config.json
@@ -0,0 +1,89 @@
+{
+  "outputs": [
+    "timestamp",
+    "channel",
+    "trapEmax",
+    "bl_mean",
+    "bl_sig",
+    "A_10",
+    "AoE"
+  ],
+  "processors": {
+    "bl_mean , bl_sig, bl_slope, bl_intercept": {
+      "function": "linear_slope_fit",
+      "module": "pygama.dsp.processors",
+      "args": [
+        "waveform[0: 1000]",
+        "bl_mean",
+        "bl_sig",
+        "bl_slope",
+        "bl_intercept"
+      ],
+      "unit": ["ADC", "ADC", "ADC", "ADC"]
+    },
+    "wf_blsub": {
+      "function": "subtract",
+      "module": "numpy",
+      "args": ["waveform", "bl_mean", "wf_blsub"],
+      "unit": "ADC"
+    },
+    "wf_pz": {
+      "function": "pole_zero",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", "db.pz_const", "wf_pz"],
+      "unit": "ADC",
+      "defaults": { "db.pz_const": "72*us" }
+    },
+    "wf_trap": {
+      "function": "trap_norm",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "10*us", "5*us", "wf_trap"],
+      "unit": "ADC"
+    },
+    "wf_atrap": {
+      "function": "asym_trap_filter",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "0.05*us", "2*us", "3*us", "wf_atrap"],
+      "unit": "ADC"
+    },
+    "tp_min, tp_max, wf_min, wf_max": {
+      "function": "min_max",
+      "module": "pygama.dsp.processors",
+      "args": ["waveform", "tp_min", "tp_max", "wf_min", "wf_max"],
+      "unit": ["ns", "ns", "ADC", "ADC"]
+    },
+    "tp_0": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_atrap", 0, "tp_max", 0, "tp_0"],
+      "unit": "ns"
+    },
+    "trapEmax": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["wf_trap", 1, "trapEmax"],
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
+      "unit": "ADC"
+    },
+    "curr10": {
+      "comment": "3rd argument allocates memory for a derivative of type float",
+      "function": "avg_current",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", 10, "curr10(len(wf_pz)-10, 'f')"],
+      "unit": "ADC/sample"
+    },
+    "A_10": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["curr10", 1, "A_10"],
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
+      "unit": "ADC"
+    },
+    "AoE": {
+      "function": "divide",
+      "module": "numpy",
+      "args": ["A_10", "trapEmax", "AoE"],
+      "unit": "1/sample"
+    }
+  }
+}
diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst
index 19583213e..c46a29087 100644
--- a/docs/source/tutorials.rst
+++ b/docs/source/tutorials.rst
@@ -3,3 +3,11 @@ Tutorials (Jupyter notebooks)
 
 Tutorials are currently available as `Jupyter notebooks on GitHub
 <https://github.com/legend-exp/pygama/tree/main/tutorials>`_.
+
+.. toctree::
+   :maxdepth: 2
+   :caption: Contents:
+
+   notebooks/LH5Files
+   notebooks/WaveformBrowser
+   notebooks/IntroToDSP
diff --git a/setup.cfg b/setup.cfg
index e985cbc41..dafa7844d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -8,6 +8,7 @@ author = The LEGEND collaboration
 maintainer = The LEGEND collaboration
 license = Apache-2.0
 license_file = LICENSE
+license_files = LICENSE
 classifiers =
     Development Status :: 4 - Beta
     Intended Audience :: Developers
@@ -31,16 +32,22 @@ project_urls =
 [options]
 packages = find:
 install_requires =
+    PyWavelets
+    colorlog
     h5py>=3.2.0
     iminuit
     matplotlib
     numba!=0.53.*,!=0.54.*
+    numexpr
     numpy>=1.21
     pandas
     parse
     pint
+    pyfcutils
     pyfftw
+    scikit-learn
     scipy
+    tables
     tqdm
 python_requires = >=3.9
 include_package_data = True
@@ -57,20 +64,20 @@ console_scripts =
 
 [options.extras_require]
 all =
-    pygama[daq,docs,test]
-daq =
-    fcutils@git+https://github.com/legend-exp/pyfcutils@v0.1.0#egg=fcutils
+    pygama[docs,test]
 docs =
     furo
     jupyter
     myst-parser
-    pygama[daq]
+    nbsphinx
+    pygama
     sphinx
     sphinx-copybutton
+    sphinx-inline-tabs
 test =
-    legend-testdata@git+https://github.com/legend-exp/legend-testdata-py@v0.2.0#egg=legend-testdata
+    legend-testdata>=0.4.2
     pre-commit
-    pygama[daq]
+    pygama
     pytest>=6.0
     pytest-cov
 
@@ -79,3 +86,6 @@ test =
 
 [flake8]
 extend-ignore = E203, E501
+
+[codespell]
+ignore-words-list = hist, gaus, nd, ans, crate, nin, puls, spms, fom
diff --git a/src/pygama/cli.py b/src/pygama/cli.py
index 348c305bc..407e9502d 100644
--- a/src/pygama/cli.py
+++ b/src/pygama/cli.py
@@ -9,7 +9,10 @@
 import numpy as np
 
 import pygama
+import pygama.logging
 from pygama.dsp import build_dsp
+from pygama.hit import build_hit
+from pygama.lgdo import show
 from pygama.raw import build_raw
 
 
@@ -42,11 +45,19 @@ def pygama_cli():
         action="store_true",
         help="""Increase the program verbosity""",
     )
+    parser.add_argument(
+        "--debug",
+        "-d",
+        action="store_true",
+        help="""Increase the program verbosity to maximum""",
+    )
 
     subparsers = parser.add_subparsers()
 
+    add_lh5ls_parser(subparsers)
     add_build_raw_parser(subparsers)
     add_build_dsp_parser(subparsers)
+    add_build_hit_parser(subparsers)
 
     if len(sys.argv) < 2:
         parser.print_usage(sys.stderr)
@@ -55,16 +66,11 @@ def pygama_cli():
     args = parser.parse_args()
 
     if args.verbose:
-        logging.basicConfig(
-            level=logging.DEBUG, format="%(name)s [%(levelname)s] %(message)s"
-        )
-        logging.getLogger("numba").setLevel(logging.INFO)
-        logging.getLogger("parse").setLevel(logging.INFO)
-        logging.getLogger("h5py").setLevel(logging.INFO)
+        pygama.logging.setup(logging.DEBUG)
+    elif args.debug:
+        pygama.logging.setup(logging.DEBUG, logging.root)
     else:
-        logging.basicConfig(
-            level=logging.INFO, format="%(name)s [%(levelname)s] %(message)s"
-        )
+        pygama.logging.setup()
 
     if args.version:
         print(pygama.__version__)  # noqa: T201
@@ -73,6 +79,28 @@ def pygama_cli():
     args.func(args)
 
 
+def add_lh5ls_parser(subparsers):
+    """Configure :func:`.lgdo.lh5_store.show` command line interface."""
+
+    parser_lh5ls = subparsers.add_parser(
+        "lh5ls", description="""Inspect LEGEND HDF5 (LH5) file contents"""
+    )
+    parser_lh5ls.add_argument(
+        "lh5_file",
+        help="""Input LH5 file.""",
+    )
+    parser_lh5ls.add_argument(
+        "lh5_group", nargs="?", help="""LH5 group.""", default="/"
+    )
+    parser_lh5ls.set_defaults(func=lh5_show_cli)
+
+
+def lh5_show_cli(args):
+    """Passes command line arguments to :func:`.lgdo.lh5_store.show`."""
+
+    show(args.lh5_file, args.lh5_group)
+
+
 def add_build_raw_parser(subparsers):
     """Configure :func:`.raw.build_raw.build_raw` command line interface"""
 
@@ -82,22 +110,19 @@ def add_build_raw_parser(subparsers):
     parser_d2r.add_argument(
         "in_stream",
         nargs="+",
-        help="""Input stream. Can be a single file, a list
-                            of files or any other input type supported by the
-                            selected streamer""",
+        help="""Input stream. Can be a single file, a list of files or any
+                other input type supported by the selected streamer""",
     )
     parser_d2r.add_argument(
         "--stream-type",
         "-t",
-        help="""Input stream type name. Use this if the
-                            stream type cannot be automatically deduced by
-                            pygama""",
+        help="""Input stream type name. Use this if the stream type cannot be
+                automatically deduced by pygama""",
     )
     parser_d2r.add_argument(
         "--out-spec",
         "-o",
-        help="""Specification for the output stream. HDF5
-                            or JSON file name""",
+        help="""Specification for the output stream. HDF5 or JSON file name""",
     )
     parser_d2r.add_argument(
         "--buffer_size", "-b", type=int, default=8192, help="""Set buffer size"""
@@ -107,8 +132,8 @@ def add_build_raw_parser(subparsers):
         "-n",
         type=int,
         default=np.inf,
-        help="""Maximum number of rows of data to process
-                            from the input file""",
+        help="""Maximum number of rows of data to process from the input
+                file""",
     )
     parser_d2r.add_argument(
         "--overwrite", "-w", action="store_true", help="""Overwrite output files"""
@@ -144,72 +169,68 @@ def add_build_dsp_parser(subparsers):
     parser_r2d.add_argument(
         "raw_lh5_file",
         nargs="+",
-        help="""Input raw LH5 file. Can be a single file or
-                            a list of them""",
+        help="""Input raw LH5 file. Can be a single file or a list of them""",
     )
     parser_r2d.add_argument(
         "--config",
         "-c",
         required=True,
-        help=""""JSON file holding configuration of signal
-                            processing routines""",
+        help=""""JSON file holding configuration of signal processing
+                 routines""",
     )
     parser_r2d.add_argument(
         "--hdf5-groups",
         "-g",
         nargs="*",
         default=None,
-        help="""Name of group in the LH5 file. By default
-                            process all base groups. Supports wildcards""",
+        help="""Name of group in the LH5 file. By default process all base
+                groups. Supports wildcards""",
     )
     parser_r2d.add_argument(
         "--output",
         "-o",
         default=None,
-        help="""Name of output file, if only one is
-                            supplied. By default, output to
-                            <input-filename>_dsp.lh5""",
+        help="""Name of output file, if only one is supplied. By default,
+                output to <input-filename>_dsp.lh5""",
     )
     parser_r2d.add_argument(
         "--database",
         "-d",
         default=None,
-        help="""JSON file to read database parameters from.
-                            Should be nested dict with channel at the top
-                            level, and parameters below that""",
+        help="""JSON file to read database parameters from.  Should be nested
+                dict with channel at the top level, and parameters below that""",
     )
     parser_r2d.add_argument(
         "--output-pars",
         "-p",
         nargs="*",
         default=None,
-        help="""List of additional output DSP parameters
-                            written to file. By default use the "outputs" list
-                            defined in in the JSON configuration file""",
+        help="""List of additional output DSP parameters written to file. By
+                default use the "outputs" list defined in in the JSON
+                configuration file""",
     )
     parser_r2d.add_argument(
         "--max-rows",
         "-n",
         default=None,
         type=int,
-        help="""Number of rows to process. By default
-                            do the whole file""",
+        help="""Number of rows to process. By default do the whole file""",
     )
     parser_r2d.add_argument(
         "--block",
         "-b",
         default=16,
         type=int,
-        help="""Number of waveforms to process
-                            simultaneously. Default is 16""",
+        help="""Number of waveforms to process simultaneously. Default is
+                16""",
     )
     parser_r2d.add_argument(
         "--chunk",
         "-k",
         default=3200,
         type=int,
-        help="""Number of waveforms to read from disk at a
-                            time. Default is 3200""",
+        help="""Number of waveforms to read from disk at a time. Default is
+                3200""",
     )
 
     group = parser_r2d.add_mutually_exclusive_group()
@@ -220,8 +241,7 @@ def add_build_dsp_parser(subparsers):
         const="r",
         dest="writemode",
         default="r",
-        help="""Overwrite file if it already exists. Default
-                       option""",
+        help="""Overwrite file if it already exists. Default option""",
     )
     group.add_argument(
         "--update",
@@ -229,8 +249,8 @@ def add_build_dsp_parser(subparsers):
         action="store_const",
         const="u",
         dest="writemode",
-        help="""Update existing file with new
-                       values. Useful with the --outpar option""",
+        help="""Update existing file with new values. Useful with the --output-pars
+                option""",
     )
     group.add_argument(
         "--append",
@@ -238,8 +258,7 @@ def add_build_dsp_parser(subparsers):
         action="store_const",
         const="a",
         dest="writemode",
-        help="""Append values to existing
-                       file""",
+        help="""Append values to existing file""",
     )
 
     parser_r2d.set_defaults(func=build_dsp_cli)
@@ -278,3 +297,115 @@ def build_dsp_cli(args):
             buffer_len=args.chunk,
             block_width=args.block,
         )
+
+
+def add_build_hit_parser(subparsers):
+    """Configure :func:`.hit.build_hit.build_hit` command line interface"""
+
+    parser_r2d = subparsers.add_parser(
+        "build-hit",
+        description="""Process LH5 dsp files and produce a hit file using a
+                       JSON configuration""",
+    )
+    parser_r2d.add_argument(
+        "dsp_lh5_file",
+        nargs="+",
+        help="""Input dsp LH5 file. Can be a single file or a list of them""",
+    )
+    parser_r2d.add_argument(
+        "--config",
+        "-c",
+        required=True,
+        help=""""JSON file holding configuration of column operations on DSP output""",
+    )
+    parser_r2d.add_argument(
+        "--hdf5-groups",
+        "-g",
+        nargs="*",
+        default=None,
+        help="""Name of group in the LH5 file. By default process all base
+                groups. Supports wildcards""",
+    )
+    parser_r2d.add_argument(
+        "--output",
+        "-o",
+        default=None,
+        help="""Name of output file, if only one is supplied. By default,
+                output to <input-filename>_hit.lh5""",
+    )
+    parser_r2d.add_argument(
+        "--max-rows",
+        "-n",
+        default=None,
+        type=int,
+        help="""Number of rows to process. By default do the whole file""",
+    )
+    parser_r2d.add_argument(
+        "--chunk",
+        "-k",
+        default=3200,
+        type=int,
+        help="""Number of waveforms to read from disk at a time. Default is
+                3200""",
+    )
+
+    group = parser_r2d.add_mutually_exclusive_group()
+    group.add_argument(
+        "--overwrite",
+        "-w",
+        action="store_const",
+        const="of",
+        dest="writemode",
+        default="w",
+        help="""Overwrite file if it already exists. Default option""",
+    )
+    group.add_argument(
+        "--update",
+        "-u",
+        action="store_const",
+        const="u",
+        dest="writemode",
+        help="""Update existing file with new values""",
+    )
+    group.add_argument(
+        "--append",
+        "-a",
+        action="store_const",
+        const="a",
+        dest="writemode",
+        help="""Append values to existing file""",
+    )
+
+    parser_r2d.set_defaults(func=build_hit_cli)
+
+
+def build_hit_cli(args):
+    """Passes command line arguments to :func:`.hit.build_hit.build_hit`."""
+
+    if len(args.dsp_lh5_file) > 1 and args.output is not None:
+        raise NotImplementedError("not possible to set multiple output file names yet")
+
+    out_files = []
+    if len(args.dsp_lh5_file) == 1:
+        if args.output is None:
+            basename = os.path.splitext(os.path.basename(args.dsp_lh5_file[0]))[0]
+            basename = basename.removesuffix("_dsp")
+            out_files.append(f"{basename}_hit.lh5")
+        else:
+            out_files.append(args.output)
+    else:
+        for file in args.dsp_lh5_file:
+            basename = os.path.splitext(os.path.basename(file))[0]
+            basename = basename.removesuffix("_dsp")
+            out_files.append(f"{basename}_hit.lh5")
+
+    for i in range(len(args.dsp_lh5_file)):
+        build_hit(
+            args.dsp_lh5_file[i],
+            args.config,
+            outfile=out_files[i],
+            lh5_tables=args.hdf5_groups,
+            n_max=args.max_rows,
+            wo_mode=args.writemode,
+            buffer_len=args.chunk,
+        )
diff --git a/src/pygama/dsp/build_dsp.py b/src/pygama/dsp/build_dsp.py
index bb1615090..772b6cceb 100644
--- a/src/pygama/dsp/build_dsp.py
+++ b/src/pygama/dsp/build_dsp.py
@@ -19,6 +19,7 @@
 import pygama.lgdo.lh5_store as lh5
 from pygama.dsp.errors import DSPFatal
 from pygama.dsp.processing_chain import build_processing_chain
+from pygama.lgdo.lgdo_utils import expand_path
 
 log = logging.getLogger(__name__)
 
@@ -27,7 +28,7 @@ def build_dsp(
     f_raw: str,
     f_dsp: str,
     dsp_config: str | dict = None,
-    lh5_tables: list[str] = None,
+    lh5_tables: list[str] | str = None,
     database: str | dict = None,
     outputs: list[str] = None,
     n_max: int = np.inf,
@@ -100,13 +101,6 @@ def build_dsp(
                 log.debug(f"table {tb} not found")
         return
 
-    if isinstance(dsp_config, str):
-        with open(dsp_config) as config_file:
-            dsp_config = json.load(config_file)
-
-    if not isinstance(dsp_config, dict):
-        raise ValueError("dsp_config must be a dict")
-
     raw_store = lh5.LH5Store()
     lh5_file = raw_store.gimme_file(f_raw, "r")
     if lh5_file is None:
@@ -116,6 +110,13 @@ def build_dsp(
     # if no group is specified, assume we want to decode every table in the file
     if lh5_tables is None:
         lh5_tables = lh5.ls(f_raw)
+    elif isinstance(lh5_tables, str):
+        lh5_tables = [lh5_tables]
+    elif not (
+        hasattr(lh5_tables, "__iter__")
+        and all(isinstance(el, str) for el in lh5_tables)
+    ):
+        raise RuntimeError("lh5_tables must be None, a string, or a list of strings")
 
     # check if group points to raw data; sometimes 'raw' is nested, e.g g024/raw
     for i, tb in enumerate(lh5_tables):
@@ -127,19 +128,13 @@ def build_dsp(
     if len(lh5_tables) == 0:
         raise RuntimeError(f"could not find any valid LH5 table in {f_raw}")
 
-    # load DSP config (default: one config file for all tables)
-    if isinstance(dsp_config, str):
-        with open(dsp_config) as config_file:
-            dsp_config = json.load(config_file)
-
     # get the database parameters. For now, this will just be a dict in a json
     # file, but eventually we will want to interface with the metadata repo
     if isinstance(database, str):
-        with open(database) as db_file:
+        with open(expand_path(database)) as db_file:
             database = json.load(db_file)
 
     if database and not isinstance(database, dict):
-        database = None
         raise ValueError("input database is not a valid JSON file or dict")
 
     if write_mode is None and os.path.isfile(f_dsp):
@@ -186,13 +181,12 @@ def build_dsp(
                 proc_chain, lh5_it.field_mask, tb_out = build_processing_chain(
                     lh5_in, dsp_config, db_dict, outputs, block_width
                 )
-                if log.level <= logging.INFO:
+                if log.getEffectiveLevel() >= logging.INFO:
                     progress_bar = tqdm(
                         desc=f"Processing table {tb}",
                         total=tot_n_rows,
                         delay=2,
-                        unit="rows",
-                        file=sys.stdout,
+                        unit=" rows",
                     )
 
             n_rows = min(tot_n_rows - start_row, n_rows)
@@ -212,13 +206,13 @@ def build_dsp(
                 write_start=write_offset + start_row,
             )
 
-            if log.level <= logging.INFO:
+            if log.getEffectiveLevel() >= logging.INFO:
                 progress_bar.update(n_rows)
 
             if start_row + n_rows >= tot_n_rows:
                 break
 
-        if log.level <= logging.INFO:
+        if log.getEffectiveLevel() >= logging.INFO:
             progress_bar.close()
 
     raw_store.write_object(dsp_info, "dsp_info", f_dsp, wo_mode="o")
diff --git a/src/pygama/dsp/processing_chain.py b/src/pygama/dsp/processing_chain.py
index 6333b3131..1bda57d58 100644
--- a/src/pygama/dsp/processing_chain.py
+++ b/src/pygama/dsp/processing_chain.py
@@ -20,6 +20,7 @@
 
 import pygama.lgdo as lgdo
 from pygama.dsp.errors import DSPFatal, ProcessingChainError
+from pygama.lgdo.lgdo_utils import expand_path
 from pygama.math.units import Quantity, Unit
 from pygama.math.units import unit_registry as ureg
 
@@ -128,19 +129,22 @@ def __init__(
         Parameters
         ----------
         proc_chain
-            DOCME
+            ProcessingChain that contains this variable
         name
-            DOCME
+            Name of variable used to look it up
         shape
-            DOCME
+            Shape of variable, without buffer_len dimension
         dtype
-            DOCME
+            Data type of variable
         grid
-            DOCME
+            Coordinate grid associated with variable. This contains the
+            period and offset of the variable. For variables where
+            is_coord is True, use this to perform unit conversions
         unit
-            DOCME
+            Unit associated with variable during I/O.
         is_coord
-            DOCME
+            If True, variable represents an array index and can be converted
+            into a unitted number using grid
         """
         assert isinstance(proc_chain, ProcessingChain) and isinstance(name, str)
         self.proc_chain = proc_chain
@@ -379,7 +383,7 @@ def add_variable(
         grid : CoordinateGrid
             for variable, containing period and offset
         unit
-            DOCME
+            unit of variable
         period
             unit with period of waveform associated with object. Do not use if
             `grid` is provided
@@ -443,22 +447,24 @@ def link_input_buffer(
 
         # Create input buffer that will be linked and returned if none exists
         if buff is None:
+            dtype = var.get_buffer().dtype
+
             if var is None:
                 raise ProcessingChainError(
                     f"{varname} does not exist and no buffer was provided"
                 )
             elif isinstance(var.grid, CoordinateGrid) and len(var.shape) == 1:
                 buff = lgdo.WaveformTable(
-                    size=self._buffer_len, wf_len=var.shape[0], dtype=var.dtype
+                    size=self._buffer_len, wf_len=var.shape[0], dtype=dtype
                 )
             elif len(var.shape) == 0:
-                buff = lgdo.Array(shape=(self._buffer_len), dtype=var.dtype)
+                buff = lgdo.Array(shape=(self._buffer_len), dtype=dtype)
             elif len(var.shape) > 0:
                 buff = lgdo.ArrayOfEqualSizedArrays(
-                    shape=(self._buffer_len, *var.shape), dtype=var.dtype
+                    shape=(self._buffer_len, *var.shape), dtype=dtype
                 )
             else:
-                buff = np.ndarray((self._buffer_len,) + var.shape, var.dtype)
+                buff = np.ndarray((self._buffer_len,) + var.shape, dtype)
 
         # Add the buffer to the input buffers list
         if isinstance(buff, np.ndarray):
@@ -511,22 +517,24 @@ def link_output_buffer(
 
         # Create output buffer that will be linked and returned if none exists
         if buff is None:
+            dtype = var.get_buffer().dtype
+
             if var is None:
                 raise ProcessingChainError(
                     varname + " does not exist and no buffer was provided"
                 )
             elif isinstance(var.grid, CoordinateGrid) and len(var.shape) == 1:
                 buff = lgdo.WaveformTable(
-                    size=self._buffer_len, wf_len=var.shape[0], dtype=var.dtype
+                    size=self._buffer_len, wf_len=var.shape[0], dtype=dtype
                 )
             elif len(var.shape) == 0:
-                buff = lgdo.Array(shape=(self._buffer_len), dtype=var.dtype)
+                buff = lgdo.Array(shape=(self._buffer_len), dtype=dtype)
             elif len(var.shape) > 0:
                 buff = lgdo.ArrayOfEqualSizedArrays(
-                    shape=(self._buffer_len, *var.shape), dtype=var.dtype
+                    shape=(self._buffer_len, *var.shape), dtype=dtype
                 )
             else:
-                buff = np.ndarray((self._buffer_len,) + var.shape, var.dtype)
+                buff = np.ndarray((self._buffer_len,) + var.shape, dtype)
 
         # Add the buffer to the output buffers list
         if isinstance(buff, np.ndarray):
@@ -579,33 +587,33 @@ def get_variable(
         r"""Parse string `expr` into a NumPy array or value, using the following
         syntax:
 
-          - numeric values are parsed into ``int``\ s or ``float``\ s
-          - units found in the :mod:`pint` package
-          - other strings are parsed into variable names. If `get_names_only` is
-            ``False``, fetch the internal buffer (creating it as needed). Else,
-            return a string of the name
-          - if a string is followed by ``(...)``, try parsing into one of the
-            following expressions:
-
-              - ``len(expr)``: return the length of the array found with `expr`
-              - ``round(expr)``: return the value found with `expr` to the
-                nearest integer
-              - ``varname(shape, type)``: allocate a new buffer with the
-                specified shape and type, using ``varname``. This is used if
-                the automatic type and shape deduction for allocating variables
-                fails
-
-          - Unary and binary operators :obj:`+`, :obj:`-`, :obj:`*`, :obj:`/`,
-            :obj:`//` are available. If a variable name is included in the
-            expression, a processor will be added to the
-            :class:`ProcessingChain` and a new buffer allocated to store the
-            output
-          - ``varname[slice]``: return the variable with a slice applied. Slice
-            values can be ``float``\ s, and will have round applied to them
-          - ``keyword = expr``: return a ``dict`` with a single element
-            pointing from keyword to the parsed `expr`. This is used for
-            `kwargs`. If `expr_only` is ``True``, raise an exception if we see
-            this.
+        - numeric values are parsed into ``int``\ s or ``float``\ s
+        - units found in the :mod:`pint` package
+        - other strings are parsed into variable names. If `get_names_only` is
+          ``False``, fetch the internal buffer (creating it as needed). Else,
+          return a string of the name
+        - if a string is followed by ``(...)``, try parsing into one of the
+          following expressions:
+
+          - ``len(expr)``: return the length of the array found with `expr`
+          - ``round(expr)``: return the value found with `expr` to the
+            nearest integer
+          - ``varname(shape, type)``: allocate a new buffer with the
+            specified shape and type, using ``varname``. This is used if
+            the automatic type and shape deduction for allocating variables
+            fails
+
+        - Unary and binary operators :obj:`+`, :obj:`-`, :obj:`*`, :obj:`/`,
+          :obj:`//` are available. If a variable name is included in the
+          expression, a processor will be added to the
+          :class:`ProcessingChain` and a new buffer allocated to store the
+          output
+        - ``varname[slice]``: return the variable with a slice applied. Slice
+          values can be ``float``\ s, and will have round applied to them
+        - ``keyword = expr``: return a ``dict`` with a single element
+          pointing from keyword to the parsed `expr`. This is used for
+          `kwargs`. If `expr_only` is ``True``, raise an exception if we see
+          this.
 
         If `get_names_only` is set to ``True``, do not fetch or allocate new
         arrays, instead return a list of variable names found in the expression.
@@ -646,6 +654,13 @@ def _parse_expr(
         if node is None:
             return None
 
+        elif isinstance(node, ast.List):
+            npparr = np.array(ast.literal_eval(expr))
+            if len(npparr.shape) == 1:
+                return npparr
+            else:
+                ProcessingChainError("only 1D arrays are supported: " + expr)
+
         elif isinstance(node, ast.Num):
             return node.n
 
@@ -817,6 +832,18 @@ def get_index(slice_value):
 
         # for name.attribute
         elif isinstance(node, ast.Attribute):
+            # If we are looking for an attribute of a module (e.g. np.pi)
+            if node.value.id in self.module_list:
+                mod = self.module_list[node.value.id]
+                attr = getattr(mod, node.attr)
+                if not isinstance(attr, (int, float)):
+                    raise ProcessingChainError(
+                        f"Attribute {node.attr} from {node.value} is not"
+                        f"an int or float..."
+                    )
+                return attr
+
+            # Otherwise this is probably a ProcChainVar
             val = self._parse_expr(node.value, expr, dry_run, var_name_list)
             if val is None:
                 return None
@@ -860,6 +887,7 @@ def _validate_name(self, name: str, raise_exception: bool = False) -> bool:
             re.match(r"\A\w+$", name)
             and name not in self.func_list
             and name not in ureg
+            and name not in self.module_list
         )
         if raise_exception and not isgood:
             raise ProcessingChainError(f"{name} is not a valid variable name")
@@ -920,6 +948,7 @@ def _round(var: ProcChainVar) -> float:  # noqa: N805
 
     # dict of functions that can be parsed by get_variable
     func_list = {"len": _length, "round": _round}
+    module_list = {"np": np, "numpy": np}
 
 
 class ProcessorManager:
@@ -1110,9 +1139,13 @@ def __init__(
                 if not d:
                     continue
                 if d not in dims_dict:
-                    raise ProcessingChainError(
-                        f"could not deduce dimension {d} for {param}"
-                    )
+                    # If it is an array lets get the length
+                    if isinstance(param, np.ndarray):
+                        dims_dict[d] = self.DimInfo(len(param), None)
+                    else:
+                        raise ProcessingChainError(
+                            f"could not deduce dimension {d} for {param}"
+                        )
                 dim_list.append(dims_dict[d])
             shape = tuple(d.length for d in dim_list)
             this_grid = dim_list[-1].grid if dim_list else None
@@ -1123,6 +1156,7 @@ def __init__(
                 is_coord = False
                 if param.is_coord is True and grid is not None:
                     unit = str(grid.period.u)
+                    this_grid = grid
                 elif (
                     isinstance(param.unit, str)
                     and param.unit in ureg
@@ -1130,6 +1164,7 @@ def __init__(
                     and ureg.is_compatible_with(grid.period, param.unit)
                 ):
                     is_coord = True
+                    this_grid = grid
 
                 param.update_auto(
                     shape=shape,
@@ -1150,6 +1185,19 @@ def __init__(
                         arshape.insert(len(arshape) + idim + 1, 1)
                 param = param.reshape(tuple(arshape))
 
+            elif isinstance(param, str):
+                # Convert string into integer buffer if appropriate
+                if np.issubdtype(dtype, np.integer):
+                    try:
+                        param = np.frombuffer(param.encode("ascii"), dtype).reshape(
+                            shape
+                        )
+                    except (ValueError):
+                        raise ProcessingChainError(
+                            f"could not convert string '{param}' into"
+                            f"byte-array of type {dtype} and shape {shape}"
+                        )
+
             elif param is not None:
                 # Convert scalar to right type, including units
                 if isinstance(param, (Quantity, Unit)):
@@ -1196,7 +1244,7 @@ class UnitConversionManager(ProcessorManager):
 
     @vectorize(nopython=True, cache=True)
     def convert(buf_in, offset_in, offset_out, period_ratio):  # noqa: N805
-        return (buf_in - offset_in) * period_ratio + offset_out
+        return (buf_in + offset_in) * period_ratio - offset_out
 
     def __init__(self, var: ProcChainVar, unit: str | Unit) -> None:
         # reference back to our processing chain
@@ -1209,7 +1257,7 @@ def __init__(self, var: ProcChainVar, unit: str | Unit) -> None:
 
         from_buffer, from_grid = var._buffer[0]
         period_ratio = from_grid.get_period(unit.period)
-        self.out_buffer = np.zeros_like(from_buffer)
+        self.out_buffer = np.zeros_like(from_buffer, dtype="float64")
         self.args = [
             from_buffer,
             from_grid.get_offset(),
@@ -1288,13 +1336,6 @@ def __init__(self, io_array: lgdo.Array, var: ProcChainVar) -> None:
         unit = io_array.attrs.get("units", None)
         var.update_auto(dtype=io_array.dtype, shape=io_array.nda.shape[1:], unit=unit)
 
-        if var.shape != io_array.nda.shape[1:] or var.dtype != io_array.dtype:
-            raise ProcessingChainError(
-                f"LGDO object "
-                f"{self.io_buf.form_datatype()}@{self.raw_buf.data} is "
-                f"incompatible with {str(self.var)}"
-            )
-
         if isinstance(var.unit, CoordinateGrid):
             if unit is None:
                 unit = var.unit.period.u
@@ -1314,6 +1355,16 @@ def __init__(self, io_array: lgdo.Array, var: ProcChainVar) -> None:
         self.var = var
         self.raw_var = var.get_buffer(unit)
 
+        if (
+            self.var.shape != self.io_array.nda.shape[1:]
+            or self.raw_var.dtype != self.io_array.dtype
+        ):
+            raise ProcessingChainError(
+                f"LGDO object "
+                f"{self.io_buf.form_datatype()} is "
+                f"incompatible with {str(self.var)}"
+            )
+
     def read(self, start: int, end: int) -> None:
         np.copyto(
             self.raw_var[0 : end - start, ...], self.raw_buf[start:end, ...], "unsafe"
@@ -1339,13 +1390,6 @@ def __init__(self, io_array: np.ArrayOfEqualSizedArrays, var: ProcChainVar) -> N
         unit = io_array.attrs.get("units", None)
         var.update_auto(dtype=io_array.dtype, shape=io_array.nda.shape[1:], unit=unit)
 
-        if var.shape != io_array.nda.shape[1:] or var.dtype != io_array.dtype:
-            raise ProcessingChainError(
-                f"LGDO object "
-                f"{self.io_buf.form_datatype()}@{self.raw_buf.data} is "
-                f"incompatible with {str(self.var)}"
-            )
-
         if isinstance(var.unit, CoordinateGrid):
             if unit is None:
                 unit = var.unit.period.u
@@ -1365,6 +1409,16 @@ def __init__(self, io_array: np.ArrayOfEqualSizedArrays, var: ProcChainVar) -> N
         self.var = var
         self.raw_var = var.get_buffer(unit)
 
+        if (
+            self.var.shape != self.io_array.nda.shape[1:]
+            or self.raw_var.dtype != self.io_array.dtype
+        ):
+            raise ProcessingChainError(
+                f"LGDO object "
+                f"{self.io_buf.form_datatype()} is "
+                f"incompatible with {str(self.var)}"
+            )
+
     def read(self, start: int, end: int) -> None:
         np.copyto(
             self.raw_var[0 : end - start, ...], self.raw_buf[start:end, ...], "unsafe"
@@ -1504,28 +1558,28 @@ def build_processing_chain(
           default. See `outputs` argument
         - ``processors`` -- configuration dictionary
 
-           - ``name1, name2`` -- dictionary. key contains comma-separated
-             names of parameters computed
-
-              - ``function`` -- string, name of function to call.  Function
-                should implement the :class:`numpy.gufunc` interface, a factory
-                function returning a ``gufunc``, or an arbitrary function that
-                can be mapped onto a ``gufunc``
-              - ``module`` -- string, name of module containing function
-              - ``args``-- list of strings or numerical values. Contains
-                list of names of computed and input parameters or
-                constant values used as inputs to function. Note that
-                outputs should be fed by reference as args! Arguments read
-                from the database are prepended with ``db``.
-              - ``kwargs`` -- dictionary. Keyword arguments for
-                :meth:`ProcesssingChain.add_processor`.
-              - ``init_args`` --  list of strings or numerical values. List
-                of names of computed and input parameters or constant values
-                used to initialize a :class:`numpy.gufunc` via a factory
-                function
-              - ``unit`` -- list of strings. Units for parameters
-              - ``defaults`` -- dictionary. Default value to be used for
-                arguments read from the database
+          - ``name1, name2`` -- dictionary. key contains comma-separated
+            names of parameters computed
+
+            - ``function`` -- string, name of function to call.  Function
+              should implement the :class:`numpy.gufunc` interface, a factory
+              function returning a ``gufunc``, or an arbitrary function that
+              can be mapped onto a ``gufunc``
+            - ``module`` -- string, name of module containing function
+            - ``args``-- list of strings or numerical values. Contains
+              list of names of computed and input parameters or
+              constant values used as inputs to function. Note that
+              outputs should be fed by reference as args! Arguments read
+              from the database are prepended with ``db``.
+            - ``kwargs`` -- dictionary. Keyword arguments for
+              :meth:`ProcesssingChain.add_processor`.
+            - ``init_args`` --  list of strings or numerical values. List
+              of names of computed and input parameters or constant values
+              used to initialize a :class:`numpy.gufunc` via a factory
+              function
+            - ``unit`` -- list of strings. Units for parameters
+            - ``defaults`` -- dictionary. Default value to be used for
+              arguments read from the database
 
     db_dict
         A nested :class:`dict` pointing to values for database arguments. As
@@ -1553,13 +1607,15 @@ def build_processing_chain(
     proc_chain = ProcessingChain(block_width, lh5_in.size)
 
     if isinstance(dsp_config, str):
-        with open(dsp_config) as f:
+        with open(expand_path(dsp_config)) as f:
             dsp_config = json.load(f)
     elif dsp_config is None:
         dsp_config = {"outputs": [], "processors": {}}
-    else:
+    elif isinstance(dsp_config, dict):
         # We don't want to modify the input!
         dsp_config = deepcopy(dsp_config)
+    else:
+        raise ValueError("dsp_config must be a dict, json file, or None")
 
     if outputs is None:
         outputs = dsp_config["outputs"]
@@ -1683,76 +1739,87 @@ def resolve_dependencies(
             log.warning(
                 f"I don't know what to do with {input_par}. Building output without it!"
             )
-        proc_chain.link_input_buffer(input_par, buf_in)
+        try:
+            proc_chain.link_input_buffer(input_par, buf_in)
+        except Exception as e:
+            raise ProcessingChainError(
+                f"Exception raised while linking input buffer {input_par}."
+            ) from e
 
     # now add the processors
     for proc_par in proc_par_list:
         recipe = processors[proc_par]
-        module = importlib.import_module(recipe["module"])
-        func = getattr(module, recipe["function"])
-        args = recipe["args"]
+        try:
+            module = importlib.import_module(recipe["module"])
+            func = getattr(module, recipe["function"])
+            args = recipe["args"]
 
-        # Initialize the new variables, if needed
-        if "unit" in recipe:
-            new_vars = [k for k in re.split(",| ", proc_par) if k != ""]
-            for i, name in enumerate(new_vars):
-                unit = recipe.get("unit", auto)
-                if isinstance(unit, list):
-                    unit = unit[i]
+            # Initialize the new variables, if needed
+            if "unit" in recipe:
+                new_vars = [k for k in re.split(",| ", proc_par) if k != ""]
+                for i, name in enumerate(new_vars):
+                    unit = recipe.get("unit", auto)
+                    if isinstance(unit, list):
+                        unit = unit[i]
 
-                proc_chain.add_variable(name, unit=unit)
+                    proc_chain.add_variable(name, unit=unit)
 
-        # get this list of kwargs
-        kwargs = recipe.get("kwargs", {})  # might also need db lookup here
+            # get this list of kwargs
+            kwargs = recipe.get("kwargs", {})  # might also need db lookup here
 
-        # if init_args are defined, parse any strings and then call func
-        # as a factory/constructor function
-        try:
-            init_args_in = recipe["init_args"]
-            init_args = []
-            init_kwargs = {}
-            for _, arg in enumerate(init_args_in):
-                for db_var in db_parser.findall(arg):
-                    try:
-                        db_node = db_dict
-                        for key in db_var[3:].split("."):
-                            db_node = db_node[key]
-                        log.debug(f"database lookup: found {db_node} for {db_var}")
-                    except (KeyError, TypeError):
+            # if init_args are defined, parse any strings and then call func
+            # as a factory/constructor function
+            try:
+                init_args_in = recipe["init_args"]
+                init_args = []
+                init_kwargs = {}
+                for _, arg in enumerate(init_args_in):
+                    for db_var in db_parser.findall(arg):
                         try:
-                            db_node = recipe["defaults"][db_var]
-                            log.debug(
-                                "database lookup: using default value of {db_node} for {db_var}"
-                            )
+                            db_node = db_dict
+                            for key in db_var[3:].split("."):
+                                db_node = db_node[key]
+                            log.debug(f"database lookup: found {db_node} for {db_var}")
                         except (KeyError, TypeError):
-                            raise ProcessingChainError(
-                                f"did not find {db_var} in database, and "
-                                f"could not find default value."
-                            )
+                            try:
+                                db_node = recipe["defaults"][db_var]
+                                log.debug(
+                                    "database lookup: using default value of {db_node} for {db_var}"
+                                )
+                            except (KeyError, TypeError):
+                                raise ProcessingChainError(
+                                    f"did not find {db_var} in database, and "
+                                    f"could not find default value."
+                                )
+
+                        if arg == db_var:
+                            arg = db_node
+                        else:
+                            arg = arg.replace(db_var, str(db_node))
 
-                    if arg == db_var:
-                        arg = db_node
+                    # see if string can be parsed by proc_chain
+                    if isinstance(arg, str):
+                        arg = proc_chain.get_variable(arg)
+                    if isinstance(arg, dict):
+                        init_kwargs.update(arg)
                     else:
-                        arg = arg.replace(db_var, str(db_node))
-
-                # see if string can be parsed by proc_chain
-                if isinstance(arg, str):
-                    arg = proc_chain.get_variable(arg)
-                if isinstance(arg, dict):
-                    init_kwargs.update(arg)
-                else:
-                    init_args.append(arg)
+                        init_args.append(arg)
 
-            expr = ", ".join(
-                [f"{a}" for a in init_args]
-                + [f"{k}={v}" for k, v in init_kwargs.items()]
-            )
-            log.debug(f"building function from init_args: {func.__name__}({expr})")
-            func = func(*init_args)
-        except KeyError:
-            pass
+                expr = ", ".join(
+                    [f"{a}" for a in init_args]
+                    + [f"{k}={v}" for k, v in init_kwargs.items()]
+                )
+                log.debug(f"building function from init_args: {func.__name__}({expr})")
+                func = func(*init_args)
+            except KeyError:
+                pass
 
-        proc_chain.add_processor(func, *args, **kwargs)
+            proc_chain.add_processor(func, *args, **kwargs)
+        except Exception as e:
+            raise ProcessingChainError(
+                "Exception raised while attempting to add processor:\n"
+                + json.dumps(recipe, indent=2)
+            ) from e
 
     # build the output buffers
     lh5_out = lgdo.Table(size=proc_chain._buffer_len)
@@ -1769,8 +1836,13 @@ def resolve_dependencies(
 
     # finally, add the output buffers to lh5_out and the proc chain
     for out_par in out_par_list:
-        buf_out = proc_chain.link_output_buffer(out_par)
-        lh5_out.add_field(out_par, buf_out)
+        try:
+            buf_out = proc_chain.link_output_buffer(out_par)
+            lh5_out.add_field(out_par, buf_out)
+        except Exception as e:
+            raise ProcessingChainError(
+                f"Exception raised while linking output buffer {out_par}."
+            ) from e
 
     field_mask = input_par_list + copy_par_list
     return (proc_chain, field_mask, lh5_out)
diff --git a/src/pygama/dsp/processors/__init__.py b/src/pygama/dsp/processors/__init__.py
index 02f1166fb..c04cf0d67 100644
--- a/src/pygama/dsp/processors/__init__.py
+++ b/src/pygama/dsp/processors/__init__.py
@@ -61,10 +61,12 @@
 
 from .bl_subtract import bl_subtract
 from .convolutions import cusp_filter, t0_filter, zac_filter
+from .dwt import discrete_wavelet_transform
 from .fftw import dft, inv_dft, psd
 from .fixed_time_pickoff import fixed_time_pickoff
 from .gaussian_filter1d import gaussian_filter1d
 from .get_multi_local_extrema import get_multi_local_extrema
+from .histogram import histogram, histogram_stats
 from .linear_slope_fit import linear_slope_fit
 from .log_check import log_check
 from .min_max import min_max
@@ -78,14 +80,16 @@
 from .multi_t_filter import multi_t_filter, remove_duplicates
 from .optimize import optimize_1pz, optimize_2pz
 from .param_lookup import param_lookup
+from .peak_snr_threshold import peak_snr_threshold
 from .pole_zero import double_pole_zero, pole_zero
 from .presum import presum
 from .pulse_injector import inject_exp_pulse, inject_sig_pulse
 from .saturation import saturation
 from .soft_pileup_corr import soft_pileup_corr, soft_pileup_corr_bl
+from .time_over_threshold import time_over_threshold
 from .time_point_thresh import time_point_thresh
 from .trap_filters import asym_trap_filter, trap_filter, trap_norm, trap_pickoff
-from .upsampler import upsampler
+from .upsampler import interpolating_upsampler, upsampler
 from .wiener_filter import wiener_filter
 from .windower import windower
 
@@ -94,12 +98,15 @@
     "cusp_filter",
     "t0_filter",
     "zac_filter",
+    "discrete_wavelet_transform",
     "dft",
     "inv_dft",
     "psd",
     "fixed_time_pickoff",
     "gaussian_filter1d",
     "get_multi_local_extrema",
+    "histogram",
+    "histogram_stats",
     "linear_slope_fit",
     "log_check",
     "min_max",
@@ -119,6 +126,7 @@
     "inject_exp_pulse",
     "inject_sig_pulse",
     "saturation",
+    "peak_snr_threshold",
     "soft_pileup_corr",
     "soft_pileup_corr_bl",
     "time_point_thresh",
@@ -127,6 +135,8 @@
     "trap_norm",
     "trap_pickoff",
     "upsampler",
+    "interpolating_upsampler",
     "wiener_filter",
     "windower",
+    "time_over_threshold",
 ]
diff --git a/src/pygama/dsp/processors/bl_subtract.py b/src/pygama/dsp/processors/bl_subtract.py
index e9a6f73c4..855f9228e 100644
--- a/src/pygama/dsp/processors/bl_subtract.py
+++ b/src/pygama/dsp/processors/bl_subtract.py
@@ -3,12 +3,13 @@
 import numpy as np
 from numba import guvectorize
 
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
 
 @guvectorize(
     ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
     "(n),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def bl_subtract(w_in: np.ndarray, a_baseline: float, w_out: np.ndarray) -> None:
     """Subtract the constant baseline from the entire waveform.
diff --git a/src/pygama/dsp/processors/convolutions.py b/src/pygama/dsp/processors/convolutions.py
index 9f11c54c9..f10b9d1ab 100644
--- a/src/pygama/dsp/processors/convolutions.py
+++ b/src/pygama/dsp/processors/convolutions.py
@@ -6,6 +6,7 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 def cusp_filter(length: int, sigma: float, flat: int, decay: int) -> Callable:
@@ -69,7 +70,10 @@ def cusp_filter(length: int, sigma: float, flat: int, decay: int) -> Callable:
     @guvectorize(
         ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
         "(n),(m)",
-        forceobj=True,
+        **nb_kwargs(
+            cache=False,
+            forceobj=True,
+        ),
     )
     def cusp_out(w_in: np.ndarray, w_out: np.ndarray) -> None:
         """
@@ -172,7 +176,10 @@ def zac_filter(length: int, sigma: float, flat: int, decay: int) -> Callable:
     @guvectorize(
         ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
         "(n),(m)",
-        forceobj=True,
+        **nb_kwargs(
+            cache=False,
+            forceobj=True,
+        ),
     )
     def zac_out(w_in: np.ndarray, w_out: np.ndarray) -> None:
         """
@@ -233,13 +240,19 @@ def t0_filter(rise: int, fall: int) -> Callable:
     if fall < 0:
         raise DSPFatal("The length of the fall section must be positive")
 
-    t0_kern = np.arange(2 / float(rise), 0, -2 / (float(rise) ** 2))
-    t0_kern = np.append(t0_kern, np.zeros(int(fall)) - (1 / float(fall)))
+    t0_kern = np.zeros(int(rise) + int(fall))
+    for i in range(int(rise)):
+        t0_kern[i] = 2 * (int(rise) - i) / (rise**2)
+    for i in range(int(rise), len(t0_kern), 1):
+        t0_kern[i] = -1 / fall
 
     @guvectorize(
         ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
         "(n),(m)",
-        forceobj=True,
+        **nb_kwargs(
+            cache=False,
+            forceobj=True,
+        ),
     )
     def t0_filter_out(w_in: np.ndarray, w_out: np.ndarray) -> None:
         """
diff --git a/src/pygama/dsp/processors/dwt.py b/src/pygama/dsp/processors/dwt.py
new file mode 100644
index 000000000..cbf6d7064
--- /dev/null
+++ b/src/pygama/dsp/processors/dwt.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from typing import Callable
+
+import numpy as np
+from numba import guvectorize
+from pywt import downcoef
+
+from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
+
+def discrete_wavelet_transform(wave_type: str, level: int) -> Callable:
+    """
+    Apply a discrete wavelet transform to the waveform and return only
+    the approximate coefficients.
+
+    Note
+    ----
+    This processor is composed of a factory function that is called using the
+    ``init_args`` argument. The input and output waveforms are passed using
+    ``args``. The output waveform dimension must be specified.
+
+
+    Parameters
+    ----------
+    wave_type
+       The wavelet type for discrete convolution ``('haar', 'db1', ...)``
+    level
+       The level of decompositions to be performed ``(1, 2, ...)``
+
+
+    JSON Configuration Example
+    --------------------------
+    .. code-block :: json
+
+        "dwt":{
+            "function": "discrete_wavelet_transform",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_blsub", "dwt(250)"],
+            "unit": "ADC",
+            "prereqs": ["wf_blsub"],
+            "init_args": ["'haar'", "3",]
+        }
+    """
+
+    if level <= 0:
+        raise DSPFatal("The level must be a positive integer")
+
+    @guvectorize(
+        ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
+        "(n),(m)",
+        **nb_kwargs,
+        forceobj=True,
+    )
+    def dwt_out(w_in: np.ndarray, w_out: np.ndarray) -> None:
+        """
+        Parameters
+        ----------
+        w_in
+           The input waveform
+        w_out
+           The approximate coefficients. The dimension of this array can be calculated
+           by ``out_dim = wf_length/(filter_length^level)``, where ``filter_length``
+           can be obtained via ``pywt.Wavelet(wave_type).dec_len``.
+        """
+        w_out[:] = np.nan
+
+        if np.isnan(w_in).any():
+            return
+
+        w_out[:] = downcoef("a", w_in, wave_type, level=level)
+
+    return dwt_out
diff --git a/src/pygama/dsp/processors/fftw.py b/src/pygama/dsp/processors/fftw.py
index d447c340a..16099ff25 100644
--- a/src/pygama/dsp/processors/fftw.py
+++ b/src/pygama/dsp/processors/fftw.py
@@ -6,6 +6,8 @@
 from numba import guvectorize
 from pyfftw import FFTW
 
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
 
 def dft(buf_in: np.ndarray, buf_out: np.ndarray) -> Callable:
     """Perform discrete Fourier transforms using the FFTW library.
@@ -51,7 +53,14 @@ def dft(buf_in: np.ndarray, buf_out: np.ndarray) -> Callable:
     typesig = "void(" + str(buf_in.dtype) + "[:, :], " + str(buf_out.dtype) + "[:, :])"
     sizesig = "(m, n)->(m, n)" if buf_in.shape == buf_out.shape else "(m, n),(m, l)"
 
-    @guvectorize([typesig], sizesig, forceobj=True)
+    @guvectorize(
+        [typesig],
+        sizesig,
+        **nb_kwargs(
+            cache=False,
+            forceobj=True,
+        ),
+    )
     def dft(wf_in: np.ndarray, dft_out: np.ndarray) -> None:
         dft_fun(wf_in, dft_out)
 
@@ -102,7 +111,14 @@ def inv_dft(buf_in: np.ndarray, buf_out: np.ndarray) -> Callable:
     typesig = "void(" + str(buf_in.dtype) + "[:, :], " + str(buf_out.dtype) + "[:, :])"
     sizesig = "(m, n)->(m, n)" if buf_in.shape == buf_out.shape else "(m, n),(m, l)"
 
-    @guvectorize([typesig], sizesig, forceobj=True)
+    @guvectorize(
+        [typesig],
+        sizesig,
+        **nb_kwargs(
+            cache=False,
+            forceobj=True,
+        ),
+    )
     def inv_dft(wf_in: np.ndarray, dft_out: np.ndarray) -> None:
         idft_fun(wf_in, dft_out)
 
@@ -159,7 +175,14 @@ def psd(buf_in: np.ndarray, buf_out: np.ndarray) -> Callable:
     typesig = "void(" + str(buf_in.dtype) + "[:, :], " + str(buf_out.dtype) + "[:, :])"
     sizesig = "(m, n)->(m, n)" if buf_in.shape == buf_out.shape else "(m, n),(m, l)"
 
-    @guvectorize([typesig], sizesig, forceobj=True)
+    @guvectorize(
+        [typesig],
+        sizesig,
+        **nb_kwargs(
+            cache=False,
+            forceobj=True,
+        ),
+    )
     def psd(wf_in: np.ndarray, psd_out: np.ndarray) -> None:
         dft_fun(wf_in, buf_dft)
         np.abs(buf_dft, psd_out)
diff --git a/src/pygama/dsp/processors/fixed_time_pickoff.py b/src/pygama/dsp/processors/fixed_time_pickoff.py
index 456dfa9b5..06cbc3189 100644
--- a/src/pygama/dsp/processors/fixed_time_pickoff.py
+++ b/src/pygama/dsp/processors/fixed_time_pickoff.py
@@ -4,38 +4,61 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
-    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
-    "(n),()->()",
-    nopython=True,
-    cache=True,
+    [
+        "void(float32[:], float32, char, float32[:])",
+        "void(float64[:], float64, char, float64[:])",
+    ],
+    "(n),(),()->()",
+    **nb_kwargs,
 )
-def fixed_time_pickoff(w_in: np.ndarray, t_in: int, a_out: np.ndarray) -> None:
-    """Pick off the waveform value at the provided index.
+def fixed_time_pickoff(w_in: np.ndarray, t_in: float, mode_in: np.int8, a_out: float):
+    """Pick off the waveform value at the provided time.
 
-    If the provided index `t_in` is out of range, return :any:`numpy.nan`.
+    For non-integral times, interpolate between samples using the method
+    selected using `mode_in`. If the provided index `t_in` is out of range,
+    return :any:`numpy.nan`.
 
     Parameters
     ----------
     w_in
-        the input waveform
+        the input waveform.
     t_in
-        the waveform index to pick off
-    a_out
-        the output pick-off value
+        the waveform index to pick off.
+    mode_in
+        character selecting which interpolation method to use. Note this
+        must be passed as a ``int8``, e.g. ``ord('i')``. Options:
 
-    JSON Configuration Example
-    --------------------------
+        * ``i`` -- integer `t_in`; equivalent to
+          :func:`~.dsp.processors.fixed_sample_pickoff`
+        * ``n`` -- nearest-neighbor interpolation; defined at all values,
+          but not continuous
+        * ``f`` -- floor, or value at previous neighbor; defined at all
+          values but not continuous
+        * ``c`` -- ceiling, or value at next neighbor; defined at all values,
+          but not continuous
+        * ``l`` -- linear interpolation; continuous at all values, but not
+          differentiable
+        * ``h`` -- Hermite cubic spline interpolation; continuous and
+          differentiable at all values but not twice-differentiable
+        * ``s`` -- natural cubic spline interpolation; continuous and
+          twice-differentiable at all values. This method is much slower
+          than the others because it utilizes the entire input waveform!
+    a_out
+        the output pick-off value.
 
+    Examples
+    --------
     .. code-block :: json
 
         "trapEftp": {
             "function": "fixed_time_pickoff",
             "module": "pygama.dsp.processors",
-            "args": ["wf_trap", "tp_0+10*us", "trapEftp"],
-            "unit": "ADC"
+            "args": ["wf_trap", "tp_0+10*us", "'h'", "trapEftp"],
+            "unit": "ADC",
         }
     """
     a_out[0] = np.nan
@@ -43,10 +66,56 @@ def fixed_time_pickoff(w_in: np.ndarray, t_in: int, a_out: np.ndarray) -> None:
     if np.isnan(w_in).any() or np.isnan(t_in):
         return
 
-    if np.floor(t_in) != t_in:
-        raise DSPFatal("The pick-off index must be an integer")
+    if t_in < 0 or t_in > len(w_in) - 1:
+        return
 
-    if int(t_in) < 0 or int(t_in) >= len(w_in):
+    i_in = int(t_in)
+    if i_in == t_in:
+        a_out[0] = w_in[i_in]
         return
 
-    a_out[0] = w_in[int(t_in)]
+    t0 = t_in - i_in
+    t1 = 1 - t0
+
+    if chr(mode_in) == "i":  # Index
+        raise DSPFatal("fixed_time_pickoff requires integer t_in when using mode 'i'")
+    elif chr(mode_in) == "n":  # Nearest-neighbor
+        a_out[0] = w_in[i_in] if t0 < 0.5 else w_in[i_in + 1]
+    elif chr(mode_in) == "f":  # Floor
+        a_out[0] = w_in[i_in]
+    elif chr(mode_in) == "c":  # Ceiling
+        a_out[0] = w_in[i_in + 1]
+    elif chr(mode_in) == "l":  # linear
+        a_out[0] = t1 * w_in[i_in] + t0 * w_in[i_in + 1]
+    elif chr(mode_in) == "h":  # Cubic hermite
+        m0 = w_in[1] - w_in[0] if i_in == 0 else (w_in[i_in + 1] - w_in[i_in - 1]) / 2
+        m1 = (
+            w_in[-1] - w_in[-2]
+            if i_in == len(w_in) - 2
+            else (w_in[i_in + 2] - w_in[i_in]) / 2
+        )
+        a_out[0] = (
+            (-2 * t1**3 + 3 * t1**2) * w_in[i_in]
+            + (-2 * t0**3 + 3 * t0**2) * w_in[i_in + 1]
+            - (t1**3 - t1**2) * m0
+            + (t0**3 - t0**2) * m1
+        )
+    elif chr(mode_in) == "s":  # Cubic spline
+        u = np.zeros(len(w_in))
+        w2 = np.zeros(len(w_in))  # second derivative values
+
+        for i in range(1, len(w_in) - 1):
+            p = 0.5 * w2[i - 1] + 2
+            w2[i] = -0.5 / p
+            u[i] = w_in[i + 1] - 2 * w_in[i] + w_in[i - 1]
+            u[i] = (3 * u[i] - 0.5 * u[i - 1]) / p
+        for i in range(len(w_in) - 2, i_in - 1, -1):
+            w2[i] = w2[i] * w2[i + 1] + u[i]
+
+        a_out[0] = (
+            t1 * w_in[i_in]
+            + t0 * w_in[i_in + 1]
+            + ((t1**3 - t1) * w2[i_in] + (t0**3 - t0) * w2[i_in + 1]) / 6.0
+        )
+    else:
+        raise DSPFatal("Unrecognized interpolation mode")
diff --git a/src/pygama/dsp/processors/gaussian_filter1d.py b/src/pygama/dsp/processors/gaussian_filter1d.py
index 6eb917449..197e90746 100644
--- a/src/pygama/dsp/processors/gaussian_filter1d.py
+++ b/src/pygama/dsp/processors/gaussian_filter1d.py
@@ -38,6 +38,8 @@
 import numpy
 from numba import guvectorize
 
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
 
 def gaussian_filter1d(sigma: int, truncate: float = 4.0) -> numpy.ndarray:
     """1-D Gaussian filter.
@@ -89,7 +91,10 @@ def _gaussian_kernel1d(sigma, radius):
             "void(int64[:], int64[:])",
         ],
         "(n),(m)",
-        forceobj=True,
+        **nb_kwargs(
+            cache=False,
+            forceobj=True,
+        ),
     )
     def gaussian_filter1d_out(wf_in, wf_out):
 
diff --git a/src/pygama/dsp/processors/get_multi_local_extrema.py b/src/pygama/dsp/processors/get_multi_local_extrema.py
index e7ec7cef3..d0a4a276a 100644
--- a/src/pygama/dsp/processors/get_multi_local_extrema.py
+++ b/src/pygama/dsp/processors/get_multi_local_extrema.py
@@ -4,20 +4,22 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
     [
-        "void(float32[:], float32, float32[:], float32[:], float32[:], float32[:], float32[:])",
-        "void(float64[:], float64, float64[:], float64[:],float64[:], float64[:], float64[:])",
+        "void(float32[:], float32, float32, float32, float32[:], float32[:], float32[:], float32[:], float32[:])",
+        "void(float64[:], float64, float64, float64, float64[:], float64[:], float64[:], float64[:], float64[:])",
     ],
-    "(n),(),(m),(m),(),(),()",
-    nopython=True,
-    cache=True,
+    "(n),(),(),(),(m),(m),(),(),()",
+    **nb_kwargs,
 )
 def get_multi_local_extrema(
     w_in: np.ndarray,
     a_delta_in: float,
+    a_abs_max_in: float,
+    a_abs_min_in: float,
     vt_max_out: np.ndarray,
     vt_min_out: np.ndarray,
     n_max_out: int,
@@ -37,6 +39,9 @@ def get_multi_local_extrema(
     a_delta_in
         the absolute level by which data must vary (in one direction) about an
         extremum in order for it to be tagged.
+    a_abs_min_in, a_abs_max_in
+        the absolute level by which data must vary (in one direction) about 0
+        in order for a maximum to be tagged.
     vt_max_out, vt_min_out
         arrays of fixed length (padded with :any:`numpy.nan`) that hold the
         indices of the identified local maxima and minima.
@@ -48,6 +53,7 @@ def get_multi_local_extrema(
     """
 
     # prepare output
+
     vt_max_out[:] = np.nan
     vt_min_out[:] = np.nan
     n_max_out[0] = np.nan
@@ -55,10 +61,12 @@ def get_multi_local_extrema(
     flag_out[0] = np.nan
 
     # initialize internal counters
+
     n_max_counter = 0
     n_min_counter = 0
 
     # Checks
+
     if np.isnan(w_in).any() or np.isnan(a_delta_in):
         return
 
@@ -83,8 +91,10 @@ def get_multi_local_extrema(
         if find_max:
             # if the sample is less than the current max by more than a_delta_in,
             # declare the previous one a maximum, then set this as the new "min"
-            if w_in[i] < w_in[imax] - a_delta_in and int(n_max_counter) < int(
-                len(vt_max_out)
+            if (
+                w_in[i] < w_in[imax] - a_delta_in
+                and int(n_max_counter) < int(len(vt_max_out))
+                and w_in[imax] > a_abs_max_in
             ):
                 vt_max_out[int(n_max_counter)] = imax
                 n_max_counter += 1
@@ -93,8 +103,10 @@ def get_multi_local_extrema(
         else:
             # if the sample is more than the current min by more than a_delta_in,
             # declare the previous one a minimum, then set this as the new "max"
-            if w_in[i] > w_in[imin] + a_delta_in and int(n_min_counter) < int(
-                len(vt_min_out)
+            if (
+                w_in[i] > w_in[imin] + a_delta_in
+                and int(n_min_counter) < int(len(vt_min_out))
+                and w_in[imin] < a_abs_min_in
             ):
                 vt_min_out[int(n_min_counter)] = imin
                 n_min_counter += 1
diff --git a/src/pygama/dsp/processors/histogram.py b/src/pygama/dsp/processors/histogram.py
new file mode 100644
index 000000000..27a3486ea
--- /dev/null
+++ b/src/pygama/dsp/processors/histogram.py
@@ -0,0 +1,173 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32[:], float32[:])",
+        "void(float64[:], float64[:], float64[:])",
+    ],
+    "(n),(m),(p)",
+    **nb_kwargs,
+)
+def histogram(
+    w_in: np.ndarray, weights_out: np.ndarray, borders_out: np.ndarray
+) -> None:
+
+    """Produces and returns an histogram of the waveform.
+
+    Parameters
+    ----------
+    w_in
+        Data to be histogrammed.
+    weights_out
+        The output histogram weights.
+    borders_out
+        The output histogram bin edges of the histogram. Length must be len(weights_out)+1
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "hist_weights, hist_borders": {
+            "function": "histogram",
+            "module": "pygama.dsp.processors.histogram",
+            "args": ["waveform", "hist_weights(100)", "hist_borders(101)"],
+            "unit": ["none", "ADC"]
+        }
+
+    Note
+    ----
+    This implementation is significantly faster than just wrapping
+    :func:`numpy.histogram`.
+
+    See Also
+    --------
+    .histogram_stats
+    """
+
+    weights_out[:] = 0
+    borders_out[:] = np.nan
+
+    if np.isnan(w_in).any():
+        return
+
+    # create the bin borders
+    borders_out[0] = min(w_in)
+    delta = 0
+
+    # number of bins
+    bin_in = len(weights_out)
+
+    # define the bin edges
+    delta = (max(w_in) - min(w_in)) / (bin_in)
+    for i in range(0, bin_in, 1):
+        borders_out[i + 1] = min(w_in) + delta * (i + 1)
+
+    # make the histogram
+    for i in range(0, len(w_in), 1):
+        for k in range(1, len(borders_out), 1):
+            if (w_in[i] - borders_out[k]) < 0:
+                weights_out[k - 1] += 1
+                break
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32[:], float32[:], float32[:],float32[:],float32)",
+        "void(float64[:], float64[:], float64[:], float64[:],float64[:],float64)",
+    ],
+    "(n),(m),(),(),(),()",
+    **nb_kwargs,
+)
+def histogram_stats(
+    weights_in: np.ndarray,
+    edges_in: np.ndarray,
+    mode_out: int,
+    max_out: float,
+    fwhm_out: float,
+    max_in: float,
+) -> None:
+
+    """Compute useful histogram-related quantities.
+
+    Parameters
+    ----------
+    weights_in
+        histogram weights.
+    edges_in
+        histogram bin edges.
+    max_in
+        if not :any:`numpy.nan`, this value is used as the histogram bin
+        content at the mode.  Otherwise the mode is computed automatically
+        (from left to right).
+    mode_out
+        the computed mode of the histogram. If `max_in` is not :any:`numpy.nan`
+        then the closest waveform index to `max_in` is returned.
+    max_out
+        the histogram bin content at the mode.
+    fwhm_out
+        the FWHM of the histogram, calculated by starting from the mode and
+        descending left and right.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "fwhm, idx_out, max_out": {
+            "function": "histogram_stats",
+            "module": "pygama.dsp.processors.histogram",
+            "args": ["hist_weights","hist_borders","idx_out","max_out","fwhm","np.nan"],
+            "unit": ["ADC", "none", "ADC"]
+        }
+
+    See Also
+    --------
+    .histogram
+    """
+
+    fwhm_out[0] = np.nan
+    mode_out[0] = np.nan
+    max_out[0] = np.nan
+
+    if np.isnan(weights_in).any():
+        return
+
+    # find global maximum search from left to right
+    max_index = 0
+    if np.isnan(max_in):
+        for i in range(0, len(weights_in), 1):
+            if weights_in[i] > weights_in[max_index]:
+                max_index = i
+
+    # is user specifies mean justfind mean index
+    else:
+        if max_in > edges_in[-2]:
+            max_index = len(weights_in) - 1
+        else:
+            for i in range(0, len(weights_in), 1):
+                if abs(max_in - edges_in[i]) < abs(max_in - edges_in[max_index]):
+                    max_index = i
+
+    mode_out[0] = max_index
+    # returns left bin edge
+    max_out[0] = edges_in[max_index]
+
+    # and the approx fwhm
+    for i in range(max_index, len(weights_in), 1):
+        if weights_in[i] <= 0.5 * weights_in[max_index] and weights_in[i] != 0:
+            fwhm_out[0] = abs(max_out[0] - edges_in[i])
+            break
+
+    # look also into the other direction
+    for i in range(0, max_index, 1):
+        if weights_in[i] >= 0.5 * weights_in[max_index] and weights_in[i] != 0:
+            if fwhm_out[0] < abs(max_out[0] - edges_in[i]):
+                fwhm_out[0] = abs(max_out[0] - edges_in[i])
+            break
diff --git a/src/pygama/dsp/processors/linear_slope_fit.py b/src/pygama/dsp/processors/linear_slope_fit.py
index 588379833..101962772 100644
--- a/src/pygama/dsp/processors/linear_slope_fit.py
+++ b/src/pygama/dsp/processors/linear_slope_fit.py
@@ -3,6 +3,8 @@
 import numpy as np
 from numba import guvectorize
 
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
 
 @guvectorize(
     [
@@ -10,8 +12,7 @@
         "void(float64[:], float64[:], float64[:], float64[:], float64[:])",
     ],
     "(n)->(),(),(),()",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def linear_slope_fit(
     w_in: np.ndarray, mean: float, stdev: float, slope: float, intercept: float
diff --git a/src/pygama/dsp/processors/log_check.py b/src/pygama/dsp/processors/log_check.py
index d6a914e57..3b74dda96 100644
--- a/src/pygama/dsp/processors/log_check.py
+++ b/src/pygama/dsp/processors/log_check.py
@@ -3,12 +3,13 @@
 import numpy as np
 from numba import guvectorize
 
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
 
 @guvectorize(
     ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
     "(n)->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def log_check(w_in: np.ndarray, w_log: np.ndarray) -> None:
     """
diff --git a/src/pygama/dsp/processors/min_max.py b/src/pygama/dsp/processors/min_max.py
index 75e7076e6..0a44c122b 100644
--- a/src/pygama/dsp/processors/min_max.py
+++ b/src/pygama/dsp/processors/min_max.py
@@ -3,6 +3,8 @@
 import numpy as np
 from numba import guvectorize
 
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
 
 @guvectorize(
     [
@@ -10,8 +12,7 @@
         "void(float64[:], float64[:], float64[:], float64[:], float64[:])",
     ],
     "(n)->(),(),(),()",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def min_max(
     w_in: np.ndarray, t_min: int, t_max: int, a_min: float, a_max: float
diff --git a/src/pygama/dsp/processors/moving_windows.py b/src/pygama/dsp/processors/moving_windows.py
index b000fab42..fab6726ca 100644
--- a/src/pygama/dsp/processors/moving_windows.py
+++ b/src/pygama/dsp/processors/moving_windows.py
@@ -4,13 +4,13 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
     ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
     "(n),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def moving_window_left(w_in: np.ndarray, length: float, w_out: np.ndarray) -> None:
     """Applies a moving average window to the waveform.
@@ -61,8 +61,7 @@ def moving_window_left(w_in: np.ndarray, length: float, w_out: np.ndarray) -> No
 @guvectorize(
     ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
     "(n),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def moving_window_right(w_in: np.ndarray, length: float, w_out: np.ndarray) -> None:
     """Applies a moving average window to the waveform from the right.
@@ -117,8 +116,7 @@ def moving_window_right(w_in: np.ndarray, length: float, w_out: np.ndarray) -> N
         "void(float64[:], float64, float64, int32, float64[:])",
     ],
     "(n),(),(),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def moving_window_multi(
     w_in: np.ndarray, length: float, num_mw: int, mw_type: int, w_out: np.ndarray
@@ -200,8 +198,7 @@ def moving_window_multi(
 @guvectorize(
     ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
     "(n),(),(m)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def avg_current(w_in: np.ndarray, length: float, w_out: np.ndarray) -> None:
     """Calculate the derivative of a waveform, averaged across `length` samples.
diff --git a/src/pygama/dsp/processors/multi_a_filter.py b/src/pygama/dsp/processors/multi_a_filter.py
index 73cfe736b..a040eb746 100644
--- a/src/pygama/dsp/processors/multi_a_filter.py
+++ b/src/pygama/dsp/processors/multi_a_filter.py
@@ -2,6 +2,7 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 from .fixed_time_pickoff import fixed_time_pickoff
 
@@ -12,21 +13,21 @@
         "void(float64[:], float64[:], float64[:])",
     ],
     "(n),(m),(m)",
-    forceobj=True,
-    cache=True,
+    **nb_kwargs,
+    forceobj=True
 )
 def multi_a_filter(w_in, vt_maxs_in, va_max_out):
-    """
-    Finds the maximums in a waveform and returns the amplitude of the wave at those points
+    """Finds the maximums in a waveform and returns the amplitude of the wave
+    at those points.
 
     Parameters
     ----------
-    w_in : array-like
-        The array of data within which amplitudes of extrema will be found
-    vt_maxs_in : array-like
-        The array of max positions for each wf
-    va_max_out : array-like
-        An array (in-place filled) of the amplitudes of the maximums of the waveform
+    w_in
+        the array of data within which amplitudes of extrema will be found.
+    vt_maxs_in
+        the array of max positions for each waveform.
+    va_max_out
+        an array (in-place filled) of the amplitudes of the maximums of the waveform.
     """
 
     # Initialize output parameters
@@ -43,4 +44,4 @@ def multi_a_filter(w_in, vt_maxs_in, va_max_out):
             "The length of your return array must be smaller than the length of your waveform"
         )
 
-    fixed_time_pickoff(w_in, vt_maxs_in, va_max_out)
+    fixed_time_pickoff(w_in, vt_maxs_in, ord("i"), va_max_out)
diff --git a/src/pygama/dsp/processors/multi_t_filter.py b/src/pygama/dsp/processors/multi_t_filter.py
index cd0a18e22..a938ee05b 100644
--- a/src/pygama/dsp/processors/multi_t_filter.py
+++ b/src/pygama/dsp/processors/multi_t_filter.py
@@ -4,6 +4,7 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 from .time_point_thresh import time_point_thresh
 
@@ -14,8 +15,8 @@
         "void(float64[:],float64[:],float64[:])",
     ],
     "(n),(n) -> (n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
+    forceobj=True,
 )
 def remove_duplicates(
     t_in: np.ndarray, vt_min_in: np.ndarray, t_out: np.ndarray
@@ -80,8 +81,8 @@ def remove_duplicates(
         "void(float64[:], float64[:], float64[:], float64[:], float64[:])",
     ],
     "(n),(),(m),(m),(m)",
+    **nb_kwargs,
     forceobj=True,
-    cache=True,
 )
 def multi_t_filter(
     w_in: np.ndarray,
diff --git a/src/pygama/dsp/processors/optimize.py b/src/pygama/dsp/processors/optimize.py
index f3839621c..5fc05a3ec 100644
--- a/src/pygama/dsp/processors/optimize.py
+++ b/src/pygama/dsp/processors/optimize.py
@@ -7,6 +7,7 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 from .pole_zero import double_pole_zero, pole_zero
 
@@ -40,6 +41,7 @@ def __call__(self, args) -> np.ndarray:
         "void(float64[:], float64, float64, float64, float64, float64[:])",
     ],
     "(n),(),(),(),()->()",
+    **nb_kwargs,
     forceobj=True,
 )
 def optimize_1pz(
@@ -124,6 +126,7 @@ def optimize_1pz(
         "void(float64[:], float64, float64, float64, float64, float64, float64, float64[:], float64[:], float64[:])",
     ],
     "(n),(),(),(),(),(),()->(),(),()",
+    **nb_kwargs,
     forceobj=True,
 )
 def optimize_2pz(
diff --git a/src/pygama/dsp/processors/param_lookup.py b/src/pygama/dsp/processors/param_lookup.py
index 36c2ae2ca..a882eb9eb 100644
--- a/src/pygama/dsp/processors/param_lookup.py
+++ b/src/pygama/dsp/processors/param_lookup.py
@@ -3,6 +3,8 @@
 import numpy as np
 from numba import from_dtype, guvectorize, types
 
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
 
 def param_lookup(
     param_dict: dict[int, float], default_val: float, dtype: str | np.dtype
@@ -18,7 +20,11 @@ def param_lookup(
     param_dict = {types.uint32(k): out_type(v) for k, v in param_dict.items()}
     default_val = out_type(default_val)
 
-    @guvectorize(["void(uint32, " + out_type.name + "[:])"], "()->()", forceobj=True)
+    @guvectorize(
+        ["void(uint32, " + out_type.name + "[:])"],
+        "()->()",
+        **nb_kwargs(cache=False, forceobj=True),
+    )
     def lookup(channel: int, val: np.ndarray) -> None:
         """Look up a value for the provided channel from a dictionary provided
         at compile time.
diff --git a/src/pygama/dsp/processors/peak_snr_threshold.py b/src/pygama/dsp/processors/peak_snr_threshold.py
new file mode 100644
index 000000000..5fe69fc36
--- /dev/null
+++ b/src/pygama/dsp/processors/peak_snr_threshold.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32[:], float32, float32, float32[:], float32[:])",
+        "void(float64[:], float64[:], float64, float64, float64[:], float64[:])",
+    ],
+    "(n),(m),(),(),(m),()",
+    **nb_kwargs,
+)
+def peak_snr_threshold(
+    w_in: np.ndarray,
+    idx_in: np.ndarray,
+    ratio_in: float,
+    width_in: int,
+    idx_out: np.ndarray,
+    n_idx_out: int,
+) -> None:
+    """Search for local minima in a window around the provided indices.
+
+    If a minimum is found it is checked whether amplitude of the minimum
+    divided by the amplitude of the waveform at the index is smaller then the
+    given ratio. If this is the case the index is copied to the output.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    idx_in
+        the array of indices of possible signal candidates.
+    ratio_in
+        ratio threshold value.
+    width_in
+        width of the local search window.
+    idx_out
+        indices of local minima.
+    n_idx_out
+        number of non-:any:`numpy.nan` indices in `idx_out`.
+    """
+
+    # prepare output
+
+    idx_out[:] = np.nan
+    n_idx_out[0] = 0
+
+    # fill output
+    k = 0
+    for i in range(len(idx_in)):
+        if not np.isnan(idx_in[i]):
+            a = int(idx_in[i]) - int(width_in)
+            b = int(idx_in[i]) + int(width_in)
+            if a < 0:
+                a = 0
+            if b >= len(w_in):
+                b = len(w_in) - 1
+            min_index = a
+            for j in range(a, b, 1):
+                if w_in[j] < w_in[min_index]:
+                    min_index = j
+            if np.absolute(w_in[min_index] / w_in[int(idx_in[i])]) < ratio_in:
+                idx_out[k] = idx_in[i]
+                k = k + 1
+    n_idx_out[0] = k
diff --git a/src/pygama/dsp/processors/pole_zero.py b/src/pygama/dsp/processors/pole_zero.py
index 7328a8c98..6bf359fcf 100644
--- a/src/pygama/dsp/processors/pole_zero.py
+++ b/src/pygama/dsp/processors/pole_zero.py
@@ -4,13 +4,13 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
     ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
     "(n),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def pole_zero(w_in: np.ndarray, t_tau: float, w_out: np.ndarray) -> None:
     """Apply a pole-zero cancellation using the provided time
@@ -54,8 +54,7 @@ def pole_zero(w_in: np.ndarray, t_tau: float, w_out: np.ndarray) -> None:
         "void(float64[:], float64, float64, float64, float64[:])",
     ],
     "(n),(),(),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def double_pole_zero(
     w_in: np.ndarray, t_tau1: float, t_tau2: float, frac: float, w_out: np.ndarray
diff --git a/src/pygama/dsp/processors/presum.py b/src/pygama/dsp/processors/presum.py
index 0157c393e..d9b7b243f 100644
--- a/src/pygama/dsp/processors/presum.py
+++ b/src/pygama/dsp/processors/presum.py
@@ -3,12 +3,13 @@
 import numpy as np
 from numba import guvectorize
 
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
 
 @guvectorize(
     ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
     "(n),(m)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def presum(w_in: np.ndarray, w_out: np.ndarray) -> None:
     """Presum the waveform.
diff --git a/src/pygama/dsp/processors/pulse_injector.py b/src/pygama/dsp/processors/pulse_injector.py
index f4c7c739c..71b791da0 100644
--- a/src/pygama/dsp/processors/pulse_injector.py
+++ b/src/pygama/dsp/processors/pulse_injector.py
@@ -5,6 +5,8 @@
 import numpy as np
 from numba import guvectorize
 
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
 
 @guvectorize(
     [
@@ -12,8 +14,7 @@
         "void(float64[:], float64, float64,float64,float64,float64[:])",
     ],
     "(n),(),(),(), ()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def inject_sig_pulse(
     wf_in: np.ndarray, t0: int, rt: float, a: float, decay: float, wf_out: np.ndarray
@@ -66,8 +67,7 @@ def inject_sig_pulse(
         "void(float64[:], float64, float64,float64,float64,float64[:])",
     ],
     "(n),(),(),(), ()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def inject_exp_pulse(
     wf_in: np.ndarray, t0: int, rt: float, a: float, decay: float, wf_out: np.ndarray
diff --git a/src/pygama/dsp/processors/saturation.py b/src/pygama/dsp/processors/saturation.py
index 09fdf3d27..1ff30cd0d 100644
--- a/src/pygama/dsp/processors/saturation.py
+++ b/src/pygama/dsp/processors/saturation.py
@@ -4,6 +4,7 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
@@ -12,8 +13,7 @@
         "void(float64[:], float64, float64[:], float64[:])",
     ],
     "(n),()->(),()",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def saturation(
     w_in: np.ndarray, bit_depth_in: int, n_lo_out: int, n_hi_out: int
@@ -61,5 +61,5 @@ def saturation(
     for i in range(0, len(w_in), 1):
         if w_in[i] == 0:
             n_lo_out[0] += 1
-        elif w_in[i] == np.power(2, int(bit_depth_in)):
+        elif w_in[i] == np.power(2, int(bit_depth_in)) - int(bit_depth_in):
             n_hi_out[0] += 1
diff --git a/src/pygama/dsp/processors/soft_pileup_corr.py b/src/pygama/dsp/processors/soft_pileup_corr.py
index 4dd5ed2d1..ae8f12c40 100644
--- a/src/pygama/dsp/processors/soft_pileup_corr.py
+++ b/src/pygama/dsp/processors/soft_pileup_corr.py
@@ -4,6 +4,7 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
@@ -12,8 +13,7 @@
         "void(float64[:], float64, float64, float64[:])",
     ],
     "(n),(),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def soft_pileup_corr(
     w_in: np.ndarray, n_in: int, tau_in: float, w_out: np.ndarray
@@ -82,8 +82,7 @@ def soft_pileup_corr(
         "void(float64[:], float64, float64, float64, float64[:])",
     ],
     "(n),(),(),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def soft_pileup_corr_bl(
     w_in: np.ndarray, n_in: int, tau_in: float, b_in: float, w_out: np.ndarray
diff --git a/src/pygama/dsp/processors/time_over_threshold.py b/src/pygama/dsp/processors/time_over_threshold.py
new file mode 100644
index 000000000..9488426e0
--- /dev/null
+++ b/src/pygama/dsp/processors/time_over_threshold.py
@@ -0,0 +1,44 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+
+
+@guvectorize(
+    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
+    "(n),()->()",
+    **nb_kwargs,
+)
+def time_over_threshold(w_in: np.ndarray, a_threshold: float, n_samples: float) -> None:
+    """Calculates the number of samples in the input waveform over a_threshold
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    a_threshold
+        the threshold value.
+    n_samples
+        the number of samples over the threshold.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "t_sat": {
+            "function": "time_over_threshold",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_pz", "a_threshold", "t_sat"],
+            "unit": "ns"
+        }
+    """
+    if np.isnan(w_in).any() or np.isnan(a_threshold):
+        n_samples[0] = np.nan
+        return
+    n_samples[0] = 0.0
+    for sample in w_in[:]:
+        if sample > a_threshold:
+            n_samples[0] += 1
diff --git a/src/pygama/dsp/processors/time_point_thresh.py b/src/pygama/dsp/processors/time_point_thresh.py
index 67f81384b..69aced306 100644
--- a/src/pygama/dsp/processors/time_point_thresh.py
+++ b/src/pygama/dsp/processors/time_point_thresh.py
@@ -4,6 +4,7 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
@@ -12,8 +13,7 @@
         "void(float64[:], float64, float64, float64, float64[:])",
     ],
     "(n),(),(),()->()",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def time_point_thresh(
     w_in: np.ndarray, a_threshold: float, t_start: int, walk_forward: int, t_out: float
@@ -75,3 +75,107 @@ def time_point_thresh(
             if w_in[i - 1] < a_threshold <= w_in[i]:
                 t_out[0] = i
                 return
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, int64, char, float32[:])",
+        "void(float64[:], float64, float64, int64, char, float64[:])",
+    ],
+    "(n),(),(),(),()->()",
+    **nb_kwargs,
+)
+def interpolated_time_point_thresh(
+    w_in: np.ndarray,
+    a_threshold: float,
+    t_start: int,
+    walk_forward: int,
+    mode_in: np.int8,
+    t_out: float,
+) -> None:
+    """Find the time where the waveform value crosses the threshold
+
+    Search performed walking either forward or backward from the starting
+    index. Use interpolation to estimate a time between samples. Interpolation
+    mode selected with `mode_in`.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    a_threshold
+        the threshold value.
+    t_start
+        the starting index.
+    walk_forward
+        the backward (``0``) or forward (``1``) search direction.
+    mode_in
+        Character selecting which interpolation method to use. Note this
+        must be passed as a ``int8``, e.g. ``ord('i')``. Options:
+
+        * ``i`` -- integer `t_in`; equivalent to
+          :func:`~.dsp.processors.fixed_sample_pickoff`
+        * ``f`` -- floor; interpolated values are at previous neighbor, so
+          threshold crossing is at next neighbor
+        * ``c`` -- ceiling, interpolated values are at next neighbor, so
+          threshold crossing is at previous neighbor
+        * ``n`` -- nearest-neighbor interpolation; threshold crossing is
+          half-way between samples
+        * ``l`` -- linear interpolation
+        * ``h`` -- Hermite cubic spline interpolation (*not implemented*)
+        * ``s`` -- natural cubic spline interpolation (*not implemented*)
+    t_out
+        the index where the waveform value crosses the threshold.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "tp_0": {
+            "function": "time_point_thresh",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_atrap", "bl_std", "tp_start", 0, "'l'", "tp_0"],
+            "unit": "ns"
+        }
+    """
+    t_out[0] = np.nan
+
+    if (
+        np.isnan(w_in).any()
+        or np.isnan(a_threshold)
+        or np.isnan(t_start)
+        or np.isnan(walk_forward)
+    ):
+        return
+
+    if t_start < 0 or t_start >= len(w_in):
+        return
+
+    i_cross = -1
+    if walk_forward > 0:
+        for i in range(int(t_start), len(w_in) - 1, 1):
+            if w_in[i] <= a_threshold < w_in[i + 1]:
+                i_cross = i
+    else:
+        for i in range(int(t_start), 1, -1):
+            if w_in[i - 1] < a_threshold <= w_in[i]:
+                i_cross = i - 1
+
+    if i_cross == -1:
+        return
+
+    if mode_in == ord("i"):  # return index before crossing
+        t_out[0] = i_cross
+    elif mode_in == ord("f"):  # return index before crossing
+        t_out[0] = i_cross + 1
+    elif mode_in == ord("c"):  # return index before crossing
+        t_out[0] = i_cross
+    elif mode_in == ord("n"):  # nearest-neighbor; return half-way between samps
+        t_out[0] = i_cross + 0.5
+    elif mode_in == ord("l"):  # linear
+        t_out[0] = i_cross + (a_threshold - w_in[i_cross]) / (
+            w_in[i_cross + 1] - w_in[i_cross]
+        )
+    else:
+        raise DSPFatal("Unrecognized interpolation mode")
diff --git a/src/pygama/dsp/processors/trap_filters.py b/src/pygama/dsp/processors/trap_filters.py
index 10dfd444f..2c411062e 100644
--- a/src/pygama/dsp/processors/trap_filters.py
+++ b/src/pygama/dsp/processors/trap_filters.py
@@ -4,6 +4,7 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
@@ -12,8 +13,7 @@
         "void(float64[:], int32, int32, float64[:])",
     ],
     "(n),(),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def trap_filter(w_in: np.ndarray, rise: int, flat: int, w_out: np.ndarray) -> None:
     """Apply a symmetric trapezoidal filter to the waveform.
@@ -78,8 +78,7 @@ def trap_filter(w_in: np.ndarray, rise: int, flat: int, w_out: np.ndarray) -> No
         "void(float64[:], int32, int32, float64[:])",
     ],
     "(n),(),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def trap_norm(w_in: np.ndarray, rise: int, flat: int, w_out: np.ndarray) -> None:
     """Apply a symmetric trapezoidal filter to the waveform, normalized by the
@@ -150,8 +149,7 @@ def trap_norm(w_in: np.ndarray, rise: int, flat: int, w_out: np.ndarray) -> None
         "void(float64[:], int32, int32, int32, float64[:])",
     ],
     "(n),(),(),()->(n)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def asym_trap_filter(
     w_in: np.ndarray, rise: int, flat: int, fall: int, w_out: np.ndarray
@@ -226,8 +224,7 @@ def asym_trap_filter(
         "void(float64[:], int32, int32, float64, float64[:])",
     ],
     "(n),(),(),()->()",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def trap_pickoff(
     w_in: np.ndarray, rise: int, flat: int, t_pickoff: float, a_out: float
diff --git a/src/pygama/dsp/processors/upsampler.py b/src/pygama/dsp/processors/upsampler.py
index d42425189..4df10ee48 100644
--- a/src/pygama/dsp/processors/upsampler.py
+++ b/src/pygama/dsp/processors/upsampler.py
@@ -1,20 +1,25 @@
 from __future__ import annotations
 
+from math import ceil, floor
+
 import numpy as np
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
     ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
     "(n),(),(m)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def upsampler(w_in: np.ndarray, upsample: float, w_out: np.ndarray) -> None:
-    """Upsamples the waveform by the number specified, a series of moving
-    windows should be applied afterwards for smoothing.
+    """Upsamples the waveform by the number specified.
+
+    Note
+    ----
+    A series of moving windows should be applied afterwards for smoothing.
 
     Parameters
     ----------
@@ -40,3 +45,170 @@ def upsampler(w_in: np.ndarray, upsample: float, w_out: np.ndarray) -> None:
             if (t_out >= 0) & (t_out < len(w_out)):
                 w_out[t_out] = w_in[t_in]
             t_out += 1
+
+
+@guvectorize(
+    ["void(float32[:], char, float32[:])", "void(float64[:], char, float64[:])"],
+    "(n),(),(m)",
+    nopython=True,
+    **nb_kwargs,
+)
+def interpolating_upsampler(
+    w_in: np.ndarray, mode_in: np.int8, w_out: np.ndarray
+) -> None:
+    """Upsamples the waveform.
+
+    Resampling ratio is set by size of `w_out` and `w_in`. Using the
+    interpolation technique specified by `mode_in` to fill newly added samples.
+
+    Parameters
+    ----------
+    w_in
+        waveform to upsample.
+    mode_in
+        character selecting which interpolation method to use. Note this
+        must be passed as a ``int8``, e.g. ``ord('i')``. Options:
+
+        * ``i`` -- only set values at original samples; fill in between
+          with zeros. Requires integer resampling ratio
+        * ``n`` -- nearest-neighbor interpolation; defined at all values,
+          but not continuous
+        * ``f`` -- floor, or value at previous neighbor; defined at all
+          values but not continuous
+        * ``c`` -- ceiling, or value at next neighbor; defined at all values,
+          but not continuous
+        * ``l`` -- linear interpolation; continuous at all values, but not
+          differentiable
+        * ``h`` -- Hermite cubic spline interpolation; continuous and
+          differentiable at all values but not twice-differentiable
+        * ``s`` -- natural cubic spline interpolation; continuous and twice-
+          differentiable at all values. This method is much slower
+          than the others because it utilizes the entire input waveform!
+    w_out
+        output array for upsampled waveform.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_up": {
+            "function": "interpolating_upsampler",
+            "module": "pygama.dsp.processors",
+            "args": ["wf", "'s'", "wf_up(len(wf)*10, period=wf.period/10)"],
+            "unit": "ADC"
+        }
+    """
+
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any():
+        return
+
+    upsample = len(w_out) / len(w_in)
+
+    if mode_in == ord("i"):  # Index
+        if upsample != int(upsample):
+            raise DSPFatal(
+                "interpolating_upsampler requires len(w_out) to be an integer multiple of len(w_in) for mode 'i'"
+            )
+        for i_in, a in enumerate(w_in):
+            i_out = int(upsample * i_in)
+            w_out[i_out] = a
+            w_out[i_out + 1 : i_out + upsample] = 0
+
+    elif mode_in == ord("n"):  # Nearest-neighbor
+        i_last = 0
+        for i_in, a in enumerate(w_in):
+            i_next = ceil(upsample * (i_in + 0.5))
+            w_out[i_last:i_next] = a
+            i_last = i_next
+        w_out[i_last:] = w_in[-1]
+
+    elif mode_in == ord("f"):  # Floor
+        i_last = 0
+        for i_in, a in enumerate(w_in):
+            i_next = ceil(upsample * (i_in + 1))
+            w_out[i_last:i_next] = a
+            i_last = i_next
+
+    elif mode_in == ord("c"):  # Ceiling
+        i_last = 0
+        for i_in, a in enumerate(w_in):
+            i_next = floor(upsample * (i_in)) + 1
+            w_out[i_last:i_next] = a
+            i_last = i_next
+        w_out[i_last:] = w_in[-1]
+
+    elif mode_in == ord("l"):  # linear
+        i_last = 0
+        for i_in, a in enumerate(w_in):
+            i_next = ceil(upsample * (i_in + 1))
+            a_next = w_in[i_in + 1] if i_in < len(w_in) - 1 else w_in[-1]
+            for i_out in range(i_last, i_next):
+                t = i_out / upsample - i_in
+                w_out[i_out] = a + t * (a_next - a)
+            i_last = i_next
+
+    elif mode_in == ord("h"):  # Cubic hermite
+        i_last = 0
+        for i_in in range(len(w_in) - 1):
+            i_next = ceil(upsample * (i_in + 1))
+            m0 = (
+                w_in[1] - w_in[0]
+                if i_in == 0
+                else (w_in[i_in + 1] - w_in[i_in - 1]) / 2
+            )
+            m1 = (
+                w_in[-1] - w_in[-2]
+                if i_in == len(w_in) - 2
+                else (w_in[i_in + 2] - w_in[i_in]) / 2
+            )
+            for i_out in range(i_last, i_next):
+                t0 = i_out / upsample - i_in
+                t1 = 1 - t0
+                w_out[i_out] = (
+                    (-2 * t1**3 + 3 * t1**2) * w_in[i_in]
+                    + (-2 * t0**3 + 3 * t0**2) * w_in[i_in + 1]
+                    - (t1**3 - t1**2) * m0
+                    + (t0**3 - t0**2) * m1
+                )
+            i_last = i_next
+
+        for i_out in range(i_last, len(w_out)):
+            t0 = i_out / upsample - i_in
+            t1 = 1 - t0
+            w_out[i_out] = (
+                (-2 * t1**3 + 3 * t1**2) * w_in[i_in]
+                + (-2 * t0**3 + 3 * t0**2) * w_in[i_in + 1]
+                - (t1**3 - t1**2) * m0
+                + (t0**3 - t0**2) * m1
+            )
+
+    elif mode_in == ord("s"):  # Cubic spline
+        u = np.zeros(len(w_in))
+        w2 = np.zeros(len(w_in))  # second derivative values
+
+        for i_in in range(1, len(w_in) - 1):
+            p = 0.5 * w2[i_in - 1] + 2
+            w2[i_in] = -0.5 / p
+            u[i_in] = w_in[i_in + 1] - 2 * w_in[i_in] + w_in[i_in - 1]
+            u[i_in] = (3 * u[i_in] - 0.5 * u[i_in - 1]) / p
+
+        i_last = len(w_out)
+        for i_in in range(len(w_in) - 2, -1, -1):
+            w2[i_in] = w2[i_in] * w2[i_in + 1] + u[i_in]
+            i_next = ceil(upsample * (i_in))
+
+            for i_out in range(i_last, i_next - 1, -1):
+                t0 = i_out / upsample - i_in
+                t1 = 1 - t0
+                w_out[i_out] = (
+                    t1 * w_in[i_in]
+                    + t0 * w_in[i_in + 1]
+                    + ((t1**3 - t1) * w2[i_in] + (t0**3 - t0) * w2[i_in + 1]) / 6.0
+                )
+            i_last = i_next
+
+    else:
+        raise DSPFatal("Unrecognized interpolation mode")
diff --git a/src/pygama/dsp/processors/wiener_filter.py b/src/pygama/dsp/processors/wiener_filter.py
index 27e5d36c4..95c870e7a 100644
--- a/src/pygama/dsp/processors/wiener_filter.py
+++ b/src/pygama/dsp/processors/wiener_filter.py
@@ -5,6 +5,7 @@
 
 import pygama.lgdo.lh5_store as lh5
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 def wiener_filter(file_name_array: list[str]) -> np.ndarray:
@@ -115,7 +116,10 @@ def psf(superpulse, fft_superpulse):
     @guvectorize(
         ["void(complex64[:], complex64[:])", "void(complex128[:], complex128[:])"],
         "(n)->(n)",
-        forceobj=True,
+        **nb_kwargs(
+            cache=False,
+            forceobj=True,
+        ),
     )
     def wiener_out(fft_w_in: np.ndarray, fft_w_out: np.ndarray) -> None:
         """
diff --git a/src/pygama/dsp/processors/windower.py b/src/pygama/dsp/processors/windower.py
index 1a26aa61c..459a82fb1 100644
--- a/src/pygama/dsp/processors/windower.py
+++ b/src/pygama/dsp/processors/windower.py
@@ -4,13 +4,13 @@
 from numba import guvectorize
 
 from pygama.dsp.errors import DSPFatal
+from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
 
 
 @guvectorize(
     ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
     "(n),(),(m)",
-    nopython=True,
-    cache=True,
+    **nb_kwargs,
 )
 def windower(w_in: np.ndarray, t0_in: int, w_out: np.ndarray) -> None:
     """Return a shorter sample of the waveform, starting at the
diff --git a/src/pygama/dsp/utils.py b/src/pygama/dsp/utils.py
new file mode 100644
index 000000000..be43fdd6e
--- /dev/null
+++ b/src/pygama/dsp/utils.py
@@ -0,0 +1,79 @@
+import os
+from collections.abc import MutableMapping
+from typing import Any, Iterator
+
+
+def getenv_bool(name: str, default: bool = False) -> bool:
+    """Get environment value as a boolean, returning True for 1, t and true
+    (caps-insensitive), and False for any other value and default if undefined.
+    """
+    val = os.getenv(name)
+    if not val:
+        return default
+    elif val.lower() in ("1", "t", "true"):
+        return True
+    else:
+        return False
+
+
+class NumbaDefaults(MutableMapping):
+    """Bare-bones class to store some Numba default options. Defaults values
+    are set from environment variables
+
+    Examples
+    --------
+    Set all default option values for a processor at once by expanding the
+    provided dictionary:
+
+    >>> from numba import guvectorize
+    >>> from pygama.dsp.utils import numba_defaults_kwargs as nb_kwargs
+    >>> @guvectorize([], "", **nb_kwargs, nopython=True) # def proc(...): ...
+
+    Customize one argument but still set defaults for the others:
+
+    >>> from pygama.dsp.utils import numba_defaults as nb_defaults
+    >>> @guvectorize([], "", **nb_defaults(cache=False) # def proc(...): ...
+
+    Override global options at runtime:
+
+    >>> from pygama.dsp.utils import numba_defaults
+    >>> from pygama.dsp import build_dsp
+    >>> # must set options before explicitly importing pygama.dsp.processors!
+    >>> numba_defaults.cache = False
+    >>> numba_defaults.boundscheck = True
+    >>> build_dsp(...) # if not explicit, processors imports happen here
+    """
+
+    def __init__(self) -> None:
+        self.cache: bool = getenv_bool("PYGAMA_CACHE")
+        self.boundscheck: bool = getenv_bool("PYGAMA_BOUNDSCHECK")
+
+    def __getitem__(self, item: str) -> Any:
+        return self.__dict__[item]
+
+    def __setitem__(self, item: str, val: Any) -> None:
+        self.__dict__[item] = val
+
+    def __delitem__(self, item: str) -> None:
+        del self.__dict__[item]
+
+    def __iter__(self) -> Iterator:
+        return self.__dict__.__iter__()
+
+    def __len__(self) -> int:
+        return len(self.__dict__)
+
+    def __call__(self, **kwargs) -> dict:
+        mapping = self.__dict__.copy()
+        mapping.update(**kwargs)
+        return mapping
+
+    def __str__(self) -> str:
+        return str(self.__dict__)
+
+    def __repr__(self) -> str:
+        return str(self.__dict__)
+
+
+numba_defaults = NumbaDefaults()
+numba_defaults_kwargs = numba_defaults
diff --git a/src/pygama/evt/__init__.py b/src/pygama/evt/__init__.py
index 76d4f607e..5e2dbeab0 100644
--- a/src/pygama/evt/__init__.py
+++ b/src/pygama/evt/__init__.py
@@ -1,5 +1,5 @@
 """
-Subpackage description
+Utilities for grouping hit data into events.
 """
 
 from pygama.evt.build_tcm import build_tcm
diff --git a/src/pygama/evt/build_tcm.py b/src/pygama/evt/build_tcm.py
index 181554461..8c23c77e8 100644
--- a/src/pygama/evt/build_tcm.py
+++ b/src/pygama/evt/build_tcm.py
@@ -7,7 +7,7 @@
 
 
 def build_tcm(
-    input_tables: list,
+    input_tables: list[tuple[str, str]],
     coin_col: str,
     hash_func: str = r"\d+",
     coin_window: float = 0,
@@ -15,44 +15,45 @@ def build_tcm(
     out_file: str = None,
     out_name: str = "tcm",
     wo_mode: str = "write_safe",
-):
-    """
+) -> lgdo.Table:
+    r"""Build a Time Coincidence Map (TCM).
+
     Given a list of input tables, create an output table containing an entry
-    list of coincidences among the inputs. Uses tcm.generate_tcm_cols().  For
-    use with the data loader.
+    list of coincidences among the inputs. Uses
+    :func:`.evt.tcm.generate_tcm_cols`. For use with the
+    :class:`~.flow.data_loader.DataLoader`.
 
     Parameters
     ----------
-    input_tables : list of tuples
-        each entry is (filename, table_name_pattern). All tables matching
-        [table_name_pattern] in [filename] will be added to the list of input
-        tables.
-    coin_col : str
+    input_tables
+        each entry is ``(filename, table_name_pattern)``. All tables matching
+        ``table_name_pattern`` in ``filename`` will be added to the list of
+        input tables.
+    coin_col
         the name of the column in each tables used to build coincidences. All
-        tables must contain a column with this name
-    hash_func : str (re) or None
-        function to map table names to ints for use in the tcm
-        str hash_func is a regexp pattern that acts on each table_name. The
-        default hash_func pull the first int out of the table name
-        setting to None will use a table's index in input_tables
-        Later can add list or dict or a function(str) --> int
-    coin_window : float
-        The clustering window width (see generate_tcm_cols)
-    window_ref : str
-        Configuration for the clustering window (see generate_tcm_cols)
-    out_file : str or None
-        Name (including path) for the output file. If None, no file will be
-        written; the tcm will just be returned in memory
-    out_name : str
-        Name for the tcm table in the output file
-    wo_mode : str
-        mode to send to LH5Store.write_object(). Typically 'w', 'o', or 'of'
+        tables must contain a column with this name.
+    hash_func
+        mapping of table names to integers for use in the TCM.  `hash_func` is
+        a regexp pattern that acts on each table name. The default `hash_func`
+        ``r"\d+"`` pulls the first integer out of the table name. Setting to
+        ``None`` will use a table's index in `input_tables`.
+    coin_window
+        the clustering window width.
+    window_ref
+        Configuration for the clustering window.
+    out_file
+        name (including path) for the output file. If ``None``, no file will be
+        written; the TCM will just be returned in memory.
+    out_name
+        name for the TCM table in the output file.
+    wo_mode
+        mode to send to :meth:`~.lgdo.lh5_store.LH5Store.write_object`.
 
-    Returns
-    -------
-    tcm : lgdo.Table
-        The tcm!
+    See Also
+    --------
+    .tcm.generate_tcm_cols
     """
+    # hash_func: later can add list or dict or a function(str) --> int.
 
     store = lgdo.LH5Store()
     coin_data = []
diff --git a/src/pygama/evt/tcm.py b/src/pygama/evt/tcm.py
index b85b7769e..370816b70 100644
--- a/src/pygama/evt/tcm.py
+++ b/src/pygama/evt/tcm.py
@@ -8,58 +8,60 @@
 
 
 def generate_tcm_cols(
-    coin_data: list,
+    coin_data: list[np.ndarray],
     coin_window: float = 0,
     window_ref: str = "last",
-    array_ids: list = None,
-    array_idxs: list = None,
-):
-    """
+    array_ids: list[int] = None,
+    array_idxs: list[int] = None,
+) -> dict[np.ndarray]:
+    r"""Generate the columns of a time coincidence map.
+
     Generate the columns of a time coincidence map from a list of arrays of
     coincidence data (e.g. hit times from different channels). Returns 3
-    equal-length ndarrays containing the coincidence index (e.g. event number),
-    array id (e.g. channel number), and array index (e.g. hit id). These can be
-    used to retrieve other data at the same tier as the input data into
-    coincidence structures.
+    equal-length :class:`numpy.ndarray`\ s containing the coincidence index
+    (e.g. event number), array ID (e.g. channel number), and array index (e.g.
+    hit ID). These can be used to retrieve other data at the same tier as the
+    input data into coincidence structures.
+
+    Makes use of :func:`pandas.concat`, :meth:`pandas.DataFrame.sort_values`,
+    :meth:`pandas.DataFrame.groupby`, and
+    :meth:`pandas.DataFrame.cumsum`/:meth:`pandas.DataFrame.count` functions:
 
-    Makes use of pandas DataFrame's concat, sort, groupby, and cumsum/count
-    functions:
-    - pull data into a DataFrame
-    - Sort events by strictly ascending value of coin_col
-    - Group hits if the difference in coin_data is less than coin_window
+    - pull data into a :class:`pandas.DataFrame`
+    - sort events by strictly ascending value of `coin_col`
+    - group hits if the difference in `coin_data` is less than `coin_window`
 
     Parameters
     ----------
-    coin_data : list of ndarrays
-        A list of ndarrays of the data to be clustered
-    coin_window : float (optional)
-        The clustering window width. coin_data within the coin_window get
-        aggregated into the same coincidence cluster. A value of 0 means an
+    coin_data
+        a list of arrays of the data to be clustered.
+    coin_window
+        the clustering window width. `coin_data` within the `coin_window` get
+        aggregated into the same coincidence cluster. A value of ``0`` means an
         equality test.
-    window_ref : str
-        When testing one datum for inclusion in a cluster, test if it is within
-        coin_window of
-        'first' -- the first element in the cluster (rigid window width) (not
-        implemented yet)
-        'last' -- the last element in the clustur (window grows until two data
-        are separated by more than coin_window)
-        In the future, can add more options, like mean/median/mode
-    array_ids : list of ints or None
-        If provided, use array_ids in place of "index in coin_data" as the
-        integer corresponding to each element of coin_data (e.g. a channel
-        number)
-    array_idxs : list of indices or None
-        If provided, use these values in places of df.index for the return
-        values of array_idx
+    window_ref
+        when testing one datum for inclusion in a cluster, test if it is within
+        `coin_window` of
+
+        - ``"first"`` -- the first element in the cluster (rigid window width)
+        - ``"last"`` -- the last element in the cluster (window grows until two
+          data are separated by more than coin_window)
+
+    array_ids
+        if provided, use `array_ids` in place of "index in coin_data" as the
+        integer corresponding to each element of `coin_data` (e.g. a channel
+        number).
+    array_idxs
+        if provided, use these values in places of the ``DataFrame`` index for
+        the return values of `array_idx`.
 
     Returns
     -------
-    col_dict : dict of ndarrays
-        keys are 'coin_idx', 'array_id', and 'array_idx'
-        coin_idx specifies which rows of the output arrays correspond to the
-        which coincidence event
-        array_id and array_idx specify the location in coin_data of each datum
-        belonging to the coincidence event
+    col_dict
+        keys are ``coin_idx``, ``array_id``, and  ``array_idx``. ``coin_idx``
+        specifies which rows of the output arrays correspond to the which
+        coincidence event ``array_id`` and ``array_idx`` specify the location
+        in ``coin_data`` of each datum belonging to the coincidence event.
     """
     dfs = []
     for ii, array in enumerate(coin_data):
@@ -78,6 +80,7 @@ def generate_tcm_cols(
     tcm["dcoin"] = tcm["coin_data"].diff()
 
     # window into coincidences
+    # In the future, can add more options, like mean/median/mode
     if window_ref == "last":
         # create the event column by comparing the time since last event to the coincindence window
         tcm["coin_idx"] = (tcm.dcoin > coin_window).cumsum()
diff --git a/src/pygama/flow/__init__.py b/src/pygama/flow/__init__.py
index 31896c850..2090b8706 100644
--- a/src/pygama/flow/__init__.py
+++ b/src/pygama/flow/__init__.py
@@ -2,6 +2,8 @@
 Subpackage description
 """
 
+from .data_loader import DataLoader
 from .datagroup import DataGroup
+from .file_db import FileDB
 
-__all__ = ["DataGroup"]
+__all__ = ["DataGroup", "DataLoader", "FileDB"]
diff --git a/src/pygama/flow/data_loader.py b/src/pygama/flow/data_loader.py
new file mode 100644
index 000000000..8b2650eae
--- /dev/null
+++ b/src/pygama/flow/data_loader.py
@@ -0,0 +1,1338 @@
+"""
+Routines for high-level data loading and skimming.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+import string
+from itertools import product
+from keyword import iskeyword
+
+import numpy as np
+import pandas as pd
+from tqdm import tqdm
+
+from pygama.flow.file_db import FileDB
+from pygama.lgdo import (
+    Array,
+    ArrayOfEqualSizedArrays,
+    LH5Store,
+    Struct,
+    Table,
+    WaveformTable,
+)
+from pygama.lgdo.vectorofvectors import build_cl, explode_arrays, explode_cl
+
+log = logging.getLogger(__name__)
+
+
+class DataLoader:
+    """Facilitate loading of processed data across several tiers.
+
+    Where possible, uses a :class:`FileDB` object so that a user can quickly
+    select a subset of cycle files for interest, and access information at each
+    processing tier.
+
+    Example JSON configuration file:
+
+    .. code-block:: json
+
+        {
+            "levels": {
+                "hit": {
+                    "tiers": ["raw", "dsp", "hit"]
+                },
+                "tcm": {
+                    "tiers": ["tcm"],
+                    "parent": "hit",
+                    "child": "evt",
+                    "tcm_cols": {
+                        "child_idx": "coin_idx",
+                        "parent_tb": "array_id",
+                        "parent_idx": "array_idx"
+                    }
+                },
+                "evt": {
+                    "tiers": ["evt"]
+                }
+            },
+            "channel_map": {}
+        }
+
+
+    Examples
+    --------
+
+    >>> from pygama.flow import DataLoader
+    >>> dl = DataLoader("loader-config.json", "filedb-config.json")
+    >>> dl.set_files("file_status == 26 and timestamp == '20220716T130443Z'")
+    >>> dl.set_datastreams([3, 6, 8], "ch")
+    >>> dl.set_cuts({"hit": "daqenergy > 1000 and AoE > 3", "evt": "muon_veto == False"})
+    >>> dl.set_output(fmt="pd.DataFrame", columns=["daqenergy", "channel"])
+    >>> data = dl.load()
+
+
+    Advanced Usage:
+
+    >>> from pygama.flow import DataLoader
+    >>> dl = DataLoader("loader-config.json", "filedb-config.json")
+    >>> dl.set_files("all")
+    >>> dl.set_datastreams([0], "ch")
+    >>> dl.set_cuts({"hit": "wf_max > 30000"})
+    >>> el = dl.build_entry_list(tcm_level="tcm", mode="any")
+    >>> el.query("hit_table == 20", inplace=True)
+    >>> dl.set_output(fmt="pd.DataFrame", columns=["daqenergy", "channel"])
+    >>> data = dl.load(el)
+    """
+
+    def __init__(
+        self,
+        config: str | dict,
+        filedb: str | dict | FileDB,
+        file_query: str = None,
+    ) -> None:
+        """
+        Parameters
+        ----------
+        config
+            configuration dictionary or JSON file, see above for specifications.
+
+        filedb
+            the loader needs a file database. It can be specified in multiple ways:
+
+            - an instance of :class:`.FileDB`.
+            - an LH5 file containing a :class:`.FileDB` (see also
+              :meth:`.FileDB.to_disk`).
+            - a :class:`.FileDB` configuration dictionary or JSON file.
+
+        file_query
+            string query that should operate on columns of a :class:`.FileDB`.
+
+        Note
+        ----
+        No data is loaded in memory at this point.
+        """
+        # declare all member variables
+        self.config: dict = None
+        self.filedb: FileDB = None
+        self.file_list: list = None
+        self.table_list = None
+        self.cuts = None
+        self.merge_files = True
+        self.output_format = "lgdo.Table"
+        self.output_columns = None
+        self.data = None
+
+        if isinstance(filedb, FileDB):
+            self.filedb = filedb
+        else:
+            self.filedb = FileDB(filedb)
+
+        # load things if available
+        if config is not None:
+            if isinstance(config, str):
+                with open(config) as f:
+                    config = json.load(f)
+            self.set_config(config)
+
+        # set the file_list
+        if file_query is not None:
+            self.file_list = list(self.filedb.df.query(file_query).index)
+
+    # --------- Get/Set/Reset Functions ----------#
+
+    def set_config(self, config: dict) -> None:
+        """Load configuration dictionary."""
+
+        if not os.path.isdir(self.filedb.data_dir):
+            raise FileNotFoundError(
+                f"{self.filedb.data_dir} (path to data root directory in FileDB) does not exist"
+            )
+
+        self.config = config
+        self.data_dir = self.filedb.data_dir
+        self.levels = list(config["levels"].keys())
+        self.tiers = {}
+        self.cut_priority = {}
+        self.evts = {}
+        self.tcms = {}
+        for level in self.levels:
+            self.tiers[level] = config["levels"][level]["tiers"]
+            # Set cut priority
+            if "parent" in config["levels"][level].keys():  # This level is a TCM
+                parent = config["levels"][level]["parent"]
+                child = config["levels"][level]["child"]
+                self.tcms[level] = config["levels"][level]
+                self.cut_priority[level] = self.cut_priority[parent] + 2
+                self.cut_priority[child] = self.cut_priority[parent] + 1
+                self.evts[child] = {"tcm": level, "parent": parent}
+
+                # Set TCM columns to lookup
+                if "tcm_cols" not in config["levels"][level].keys():
+                    log.warning(
+                        f"TCM levels, e.g. {level}, need to specify the TCM lookup columns"
+                    )
+            else:
+                self.cut_priority[level] = 0
+
+        # Set channel map
+        if isinstance(config["channel_map"], dict):
+            self.channel_map = config["channel_map"]
+        elif isinstance(config["channel_map"], str):
+            with open(config["channel_map"]) as f:
+                self.channel_map = json.load(f)
+        else:
+            log.warning("Channel map must be dict or path to JSON file")
+
+    def set_files(self, query: str | list[str]) -> None:
+        """Apply a file selection.
+
+        Sets `self.file_list`, which is a list of indices corresponding to the
+        rows in the file database.
+
+        Parameters
+        ----------
+        query
+            if single string, defines an operation on the file database columns
+            supported by :meth:`pandas.DataFrame.query`. In addition, the
+            ``all`` keyword is supported to select all files in the database.
+            If list of strings, will be interpreted as key (cycle timestamp) list.
+
+        Note
+        ----
+        Call this function before any other operation. A second call to
+        :meth:`set_files` does not replace the current file list, which gets
+        instead integrated with the new list. Use :meth:`reset` to reset the
+        file query.
+
+        Example
+        -------
+        >>> dl.set_files("file_status == 26 and timestamp == '20220716T130443Z'")
+        """
+
+        if isinstance(query, (list, tuple)) and query:
+            query = " or ".join([f"timestamp == '{key}'" for key in query])
+
+        if isinstance(query, str):
+            if query.replace(" ", "") == "all":
+                inds = list(self.filedb.df.index)
+            else:
+                inds = list(
+                    self.filedb.df.query(query, engine="python", inplace=False).index
+                )
+        else:
+            raise ValueError("bad query format")
+
+        if not inds:
+            log.warning("no files matching selection found")
+
+        if self.file_list is None:
+            self.file_list = inds
+        else:
+            self.file_list += inds
+
+    def get_file_list(self) -> pd.DataFrame:
+        """
+        Returns a copy of the file database with its dataframe pared down to
+        the current file list.
+        """
+        return self.filedb.df.iloc[self.file_list]
+
+    # TODO Make this able to handle more complicated requests
+    def set_datastreams(self, ds: list | tuple | np.ndarray, word: str) -> None:
+        """Apply selection on data streams (or channels).
+
+        Sets `self.table_list`.
+
+        Parameters
+        ----------
+        ds
+            identifies the detectors of interest. Can be a list of detector
+            names, serial numbers, or channels or a list of subsystems of
+            interest e.g.  ``ged``.
+        word
+            the type of identifier used in ds. Should be a key in the given
+            channel map or a word defined in the configuration file.
+
+        Example
+        -------
+        >>> dl.set_datastreams(np.arange(40, 45), "ch")
+        """
+        if self.table_list is None:
+            self.table_list = {}
+
+        ds = list(ds)
+
+        found = False
+        for level in self.levels:
+            tier = self.tiers[level][0]
+
+            template = self.filedb.table_format[tier]
+            fm = string.Formatter()
+            parse_arr = np.array(list(fm.parse(template)))
+            names = list(parse_arr[:, 1])  # fields required to generate file name
+            if len(names) > 0:
+                keyword = names[0]
+
+            if word == keyword:
+                found = True
+                if level in self.table_list.keys():
+                    self.table_list[level] += ds
+                else:
+                    self.table_list[level] = ds
+
+        if not found:
+            # look for word in channel map
+            raise NotImplementedError
+
+    def set_cuts(self, cuts: dict | list) -> None:
+        """Apply a selection on columns in the data tables.
+
+        Parameters
+        ----------
+        cut
+            the cuts on the columns of the data table, e.g. ``trapEftp_cal >
+            1000``.  If passing a dictionary, the dictionary should be
+            structured as ``dict[tier] = cut_expr``. If passing a list, each
+            item in the array should be able to be applied on one level of
+            tables. The cuts at different levels will be joined with an AND.
+
+        Example
+        -------
+        >>> dl.set_cuts({"raw": "daqenergy > 1000", "hit": "AoE > 3"})
+        """
+        if self.cuts is None:
+            self.cuts = {}
+        if isinstance(cuts, dict):
+            # verify the correct structure
+            for key, value in cuts.items():
+                if not (key in self.levels and isinstance(value, str)):
+                    raise ValueError(
+                        r"cuts dictionary must be in the format \{ level: string \}"
+                    )
+                if key in self.cuts.keys():
+                    self.cuts[key] += " and " + value
+                else:
+                    self.cuts[key] = value
+        elif isinstance(cuts, list):
+            raise NotImplementedError
+            self.cuts = {}
+            # TODO Parse strings to match column names so you don't have to specify which level it is
+
+    def set_output(
+        self, fmt: str = None, merge_files: bool = None, columns: list = None
+    ) -> None:
+        """
+        Set the parameters for the output format of load
+
+        Parameters
+        ---------
+        fmt
+            ``lgdo.Table`` or ``pd.DataFrame``.
+        merge_files
+            If ``True``, information from multiple files will be merged into
+            one table.
+        columns
+            The columns that should be copied into the output.
+
+        Example
+        -------
+        >>> dl.set_output(fmt="pd.DataFrame", merge_files=False, columns=["daqenergy", "trapEmax", "channel"])
+        """
+        if fmt not in ["lgdo.Table", "pd.DataFrame", None]:
+            raise ValueError(f"'{fmt}' output format not supported")
+
+        if fmt is not None:
+            self.output_format = fmt
+        if merge_files is not None:
+            self.merge_files = merge_files
+        if columns is not None:
+            self.output_columns = columns
+
+    def reset(self):
+        """Resets all fields to their default values, as if this is a newly
+        created data loader.
+        """
+        self.file_list = None
+        self.table_list = None
+        self.cuts = None
+        self.merge_files = True
+        self.output_format = "lgdo.Table"
+        self.output_columns = None
+        self.data = None
+
+    # ------------- Applying Cuts/Loading Data --------------#
+
+    # TODO: mode
+    def build_entry_list(
+        self,
+        tcm_level: str = None,
+        tcm_table: int | str = None,
+        mode: str = "only",
+        save_output_columns: bool = False,
+        in_memory: bool = True,
+        output_file: str = None,
+    ) -> dict[int, pd.DataFrame] | pd.DataFrame | None:
+        """Applies cuts to the tables and files of interest.
+
+        Can only load up to two levels, those joined by `tcm_level`.
+
+        Parameters
+        ----------
+        tcm_level
+            the type of TCM to be used. If ``None``, will only return
+            information from lowest level.
+        tcm_table
+            the identifier of the table inside this TCM level that you want to
+            use.  If unspecified, there must only be one table inside a TCM
+            file in `tcm_level`.
+        mode
+            if ``any``, returns every hit in the event if any hit in the event
+            passes the cuts. If ``only``, only returns hits that pass the cuts.
+        save_output_columns
+            if ``True``, saves any columns needed for both the cut and the
+            output to the `self.entry_list`.
+        in_memory
+            if ``True``, returns the generated entry list in memory.
+        output_file
+            HDF5 file name to write the entry list to.
+
+        Returns
+        -------
+        entries
+            the entry list containing columns for ``{parent}_idx``,
+            ``{parent}_table``, ``{child}_idx`` and output columns if
+            applicable.  Only returned if `in_memory` is ``True``.
+
+        Note
+        ----
+        Does *not* load the column information into memory. This is done by
+        :meth:`.load`.
+        """
+        if self.file_list is None:
+            raise ValueError("You need to make a query on filedb, use set_files()")
+
+        if not in_memory and output_file is None:
+            raise ValueError("If in_memory is False, need to specify an output file")
+
+        if in_memory:
+            entries = {}
+
+        log.debug("generating entry list determined by cuts")
+
+        # Default columns in the entry_list
+        if tcm_level is None:
+            return self.build_hit_entries(save_output_columns, in_memory, output_file)
+
+        # Assumes only one tier in a TCM level
+        parent = self.tcms[tcm_level]["parent"]
+        child = self.tcms[tcm_level]["child"]
+        entry_cols = [
+            f"{parent}_table",
+            f"{parent}_idx",
+            f"{child}_idx",
+        ]
+        if save_output_columns:
+            for_output = []
+
+        # Find out which columns are needed for any cuts
+        cut_cols = {}
+
+        for level in [child, parent]:
+            cut_cols[level] = []
+            if self.cuts is not None and level in self.cuts.keys():
+                cut = self.cuts[level]
+            else:
+                cut = ""
+            # String parsing to determine which columns need to be loaded
+            # Matches any valid python variable name
+            terms = re.findall(r"[^\W0-9]\w*", cut)
+            for term in terms:
+                # Assumes that column names are valid python variable names
+                if term.isidentifier() and not iskeyword(term):
+                    cut_cols[level].append(term)
+                    # Add column to entry_cols if they are needed for both the cut and the output
+                    if self.output_columns is not None:
+                        if (
+                            term in self.output_columns
+                            and term not in entry_cols
+                            and save_output_columns
+                        ):
+                            for_output.append(term)
+
+        if save_output_columns:
+            entry_cols += for_output
+
+        sto = LH5Store()
+
+        # Make the entry list for each file
+        for file in self.file_list:
+            # Get the TCM specified
+            # Assumes that each TCM level only has one tier
+            tcm_tier = self.tiers[tcm_level][0]
+            tcm_tables = self.filedb.df.loc[file, f"{tcm_tier}_tables"]
+            if len(tcm_tables) > 1 and tcm_table is None:
+                raise ValueError(
+                    f"There are {len(tcm_tables)} TCM tables, need to specify which to use"
+                )
+            else:
+                if tcm_table is not None:
+                    if tcm_table in tcm_tables:
+                        tcm_tb = tcm_table
+                    else:
+                        raise ValueError(
+                            f"Table {tcm_table} doesn't exist in {tcm_level}"
+                        )
+                else:
+                    tcm_tb = tcm_tables[0]
+
+            tcm_path = os.path.join(
+                self.data_dir,
+                self.filedb.tier_dirs[tcm_tier].lstrip("/"),
+                self.filedb.df.iloc[file][f"{tcm_tier}_file"].lstrip("/"),
+            )
+
+            if not os.path.exists(tcm_path):
+                raise FileNotFoundError(f"Can't find TCM file for {tcm_level}")
+
+            tcm_table_name = self.get_table_name(tcm_tier, tcm_tb)
+            tcm_lgdo, _ = sto.read_object(tcm_table_name, tcm_path)
+            # Have to do some hacky stuff until I get a get_dataframe() method
+            tcm_lgdo[self.tcms[tcm_level]["tcm_cols"]["child_idx"]] = Array(
+                nda=explode_cl(tcm_lgdo["cumulative_length"].nda)
+            )
+            tcm_lgdo.pop("cumulative_length")
+            tcm_tb = Table(col_dict=tcm_lgdo)
+            f_entries = tcm_tb.get_dataframe()
+            renaming = {
+                self.tcms[tcm_level]["tcm_cols"]["child_idx"]: f"{child}_idx",
+                self.tcms[tcm_level]["tcm_cols"]["parent_tb"]: f"{parent}_table",
+                self.tcms[tcm_level]["tcm_cols"]["parent_idx"]: f"{parent}_idx",
+            }
+            f_entries.rename(columns=renaming, inplace=True)
+            if self.merge_files:
+                f_entries["file"] = file
+            # At this point, should have a list of all available hits/evts joined by tcm
+
+            if mode == "any":
+                drop_idx = None
+
+            # Perform cuts specified for child or parent level, in that order
+            for level in [child, parent]:
+                if self.cuts is None or level not in self.cuts.keys():
+                    continue
+                cut = self.cuts[level]
+                col_tiers = self.get_tiers_for_col(cut_cols[level], merge_files=False)
+
+                # Tables in first tier of event should be the same for all tiers in one level
+                tables = self.filedb.df.loc[file, f"{self.tiers[level][0]}_tables"]
+                if self.table_list is not None:
+                    if level in self.table_list.keys():
+                        tables = self.table_list[level]
+                # Cut any rows of TCM not relating to requested tables
+                if level == parent:
+                    f_entries.query(f"{level}_table in {tables}", inplace=True)
+
+                for tb in tables:
+                    tb_table = None
+                    if level == parent:
+                        tcm_idx = f_entries.query(f"{level}_table == {tb}").index
+                    else:
+                        tcm_idx = f_entries.index
+                    idx_mask = f_entries.loc[tcm_idx, f"{level}_idx"]
+                    for tier in self.tiers[level]:
+                        tier_path = os.path.join(
+                            self.data_dir,
+                            self.filedb.tier_dirs[tier].lstrip("/"),
+                            self.filedb.df.loc[file, f"{tier}_file"].lstrip("/"),
+                        )
+                        if tier in col_tiers[file]["tables"].keys():
+                            if tb in col_tiers[file]["tables"][tier]:
+                                table_name = self.get_table_name(tier, tb)
+                                tier_table, _ = sto.read_object(
+                                    table_name,
+                                    tier_path,
+                                    field_mask=cut_cols[level],
+                                    idx=idx_mask.tolist(),
+                                )
+                                if tb_table is None:
+                                    tb_table = tier_table
+                                else:
+                                    tb_table.join(tier_table)
+                    if tb_table is None:
+                        continue
+                    tb_df = tb_table.get_dataframe()
+                    tb_df.query(cut, inplace=True)
+                    idx_match = f_entries.query(f"{level}_idx in {list(tb_df.index)}")
+                    if level == parent:
+                        idx_match = idx_match.query(f"{level}_table == {tb}")
+                    if mode == "only":
+                        keep_idx = idx_match.index
+                        drop_idx = set.symmetric_difference(
+                            set(tcm_idx), list(keep_idx)
+                        )
+                        f_entries.drop(drop_idx, inplace=True)
+                    elif mode == "any":
+                        evts = idx_match[f"{child}_idx"].unique()
+                        keep_idx = f_entries.query(f"{child}_idx in {evts}").index
+                        drop = set.symmetric_difference(
+                            set(f_entries.index), list(keep_idx)
+                        )
+                        if drop_idx is None:
+                            drop_idx = drop
+                        else:
+                            drop_idx = set.intersection(drop_idx, drop)
+                    else:
+                        raise ValueError("mode must be either 'any' or 'only'")
+
+                    if save_output_columns:
+                        for col in tb_df.columns:
+                            if col in for_output:
+                                f_entries.loc[keep_idx, col] = tb_df[col].tolist()
+                    # end for each table loop
+                # end for each level loop
+            if mode == "any":
+                f_entries.drop(drop_idx, inplace=True)
+            f_entries.reset_index(inplace=True, drop=True)
+            if in_memory:
+                entries[file] = f_entries
+            if output_file:
+                # Convert f_entries DataFrame to Struct
+                f_dict = f_entries.to_dict("list")
+                f_struct = Struct(f_dict)
+                if self.merge_files:
+                    sto.write_object(f_struct, "entries", output_file, wo_mode="a")
+                else:
+                    sto.write_object(
+                        f_struct, f"entries/{file}", output_file, wo_mode="a"
+                    )
+            # end for each file loop
+
+        if in_memory:
+            if self.merge_files:
+                entries = pd.concat(entries.values(), ignore_index=True)
+            return entries
+
+    def build_hit_entries(
+        self,
+        save_output_columns: bool = False,
+        in_memory: bool = True,
+        output_file: str = None,
+    ) -> dict[int, pd.DataFrame] | pd.DataFrame | None:
+        """Called by :meth:`.build_entry_list` to handle the case when
+        `tcm_level` is unspecified.
+
+        Ignores any cuts set on levels above lowest level.
+
+        Parameters
+        ----------
+        save_output_columns
+            If ``True``, saves any columns needed for both the cut and the
+            output to the entry list.
+        in_memory
+            If ``True``, returns the generated entry list in memory.
+        output_file
+            HDF5 file name to write the entry list to.
+
+        Returns
+        -------
+        entries
+            the entry list containing columns for ``{low_level}_idx``,
+            ``{low_level}_table``, and output columns if
+            applicable.  Only returned if `in_memory` is ``True``.
+        """
+        low_level = self.levels[0]
+        if in_memory:
+            entries = {}
+        entry_cols = [f"{low_level}_table", f"{low_level}_idx"]
+
+        log.debug(
+            f"generating (hit-oriented) entry list corresponding to cuts on the {low_level} level"
+        )
+
+        # find out which columns are needed for the cut
+        cut_cols = []
+        cut = ""
+        if self.cuts is not None and low_level in self.cuts.keys():
+            cut = self.cuts[low_level]
+            # do cut expression string parsing to determine which columns need to be loaded
+            # this matches any valid python variable name
+            terms = re.findall(r"[^\W0-9]\w*", cut)
+            for term in terms:
+                if not iskeyword(term):
+                    cut_cols.append(term)
+                    # Add column to entry_cols if they are needed for both the cut and the output
+                    if self.output_columns is not None:
+                        if (
+                            term in self.output_columns
+                            and term not in entry_cols
+                            and save_output_columns
+                        ):
+                            entry_cols.append(term)
+
+        log.debug(f"need to load {cut_cols} columns for applying cuts")
+        col_tiers = self.get_tiers_for_col(cut_cols, merge_files=False)
+
+        sto = LH5Store()
+
+        if log.getEffectiveLevel() >= logging.INFO:
+            progress_bar = tqdm(
+                desc="Building entry list",
+                total=len(self.file_list),
+                delay=2,
+                unit=" keys",
+            )
+
+        # now we loop over the files in our list
+        for file in self.file_list:
+
+            if log.getEffectiveLevel() >= logging.INFO:
+                progress_bar.update()
+                progress_bar.set_postfix(key=self.filedb.df.iloc[file]["timestamp"])
+
+            log.debug(
+                f"building entry list for cycle {self.filedb.df.iloc[file]['timestamp']}"
+            )
+
+            # this dataframe will be associated with the file and will contain
+            # the columns needed for the cut as well as the columns requested
+            # for the output
+            f_entries = pd.DataFrame(columns=entry_cols)
+
+            # check if we were asked to consider only certain data streams
+            if self.table_list is not None:
+                if low_level in self.table_list.keys():
+                    tables = self.table_list[low_level]
+            else:
+                tables = self.filedb.df.loc[file, f"{self.tiers[low_level][0]}_tables"]
+
+            # now loop over these data streams
+            for tb in tables:
+                tb_table = None
+
+                # no cut is set, let's include all indices for the data stream
+                if cut == "":
+                    tier = self.tiers[low_level][0]
+                    # reconstruct absolute path to tier file
+                    tier_path = os.path.join(
+                        self.data_dir,
+                        self.filedb.tier_dirs[tier].lstrip("/"),
+                        self.filedb.df.iloc[file][f"{tier}_file"].lstrip("/"),
+                    )
+                    # now read how many rows are there in the file
+                    table_name = self.get_table_name(tier, tb)
+                    n_rows = sto.read_n_rows(table_name, tier_path)
+                    tb_df = pd.DataFrame(
+                        {
+                            f"{low_level}_idx": np.arange(n_rows),
+                            f"{low_level}_table": tb,
+                        }
+                    )
+                    if self.merge_files:
+                        tb_df["file"] = file
+                else:
+                    # loop over tiers in the lowest level
+                    for tier in self.tiers[low_level]:
+                        # is the tier involved, considered the columns on which cuts are applied?
+                        if (
+                            tier in col_tiers[file]["tables"].keys()
+                            and tb in col_tiers[file]["tables"][tier]
+                        ):
+                            # reconstruct absolute path to tier file
+                            tier_path = os.path.join(
+                                self.data_dir,
+                                self.filedb.tier_dirs[tier].lstrip("/"),
+                                self.filedb.df.iloc[file][f"{tier}_file"].lstrip("/"),
+                            )
+
+                            # load the data from the tier file, just the columns needed for the cut
+                            table_name = self.get_table_name(tier, tb)
+                            tier_tb, _ = sto.read_object(
+                                table_name, tier_path, field_mask=cut_cols
+                            )
+                            # join eveything in one table
+                            if tb_table is None:
+                                tb_table = tier_tb
+                            else:
+                                tb_table.join(tier_tb)
+
+                    if tb_table is None:
+                        continue
+                    # convert to DataFrame and apply cuts
+                    tb_df = tb_table.get_dataframe()
+                    tb_df.query(cut, inplace=True)
+                    tb_df[f"{low_level}_table"] = tb
+                    tb_df[f"{low_level}_idx"] = tb_df.index
+
+                # final DataFrame
+                f_entries = pd.concat((f_entries, tb_df), ignore_index=True)[entry_cols]
+                # end tb loop
+            if self.merge_files:
+                f_entries["file"] = file
+            if in_memory:
+                entries[file] = f_entries
+            if output_file:
+                # Convert f_entries DataFrame to Struct
+                f_dict = f_entries.to_dict("list")
+                f_struct = Struct(f_dict)
+                if self.merge_files:
+                    sto.write_object(f_struct, "entries", output_file, wo_mode="a")
+                else:
+                    sto.write_object(
+                        f_struct, f"entries/{file}", output_file, wo_mode="a"
+                    )
+            # end file loop
+
+        if log.getEffectiveLevel() >= logging.INFO:
+            progress_bar.close()
+
+        if in_memory:
+            if self.merge_files:
+                entries = pd.concat(entries.values(), ignore_index=True)
+            return entries
+
+    # TODO : support chunked reading of entry_list from disk
+    def load(
+        self,
+        entry_list: pd.DataFrame = None,
+        in_memory: bool = True,
+        output_file: str = None,
+        orientation: str = "hit",
+        tcm_level: str = None,
+    ) -> None | Table | Struct | pd.DataFrame:
+        """Loads the requested columns in `self.output_columns` for the entries
+        in the given `entry_list`.
+
+        Parameters
+        ----------
+        entry_list
+            the output of :meth:`.build_entry_list`.
+        in_memory
+            if ``True``, returns the loaded data in memory and stores in
+            `self.data`.
+        output_file
+            if not ``None``, writes the loaded data to the specified file.
+        orientation
+            specifies the orientation of the output table. Can be ``hit`` or
+            ``evt``.
+        tcm_level
+            which TCM was used to create the ``entry_list``.
+
+        Returns
+        -------
+        data
+            The data loaded from disk, as specified by `self.output_format`,
+            `self.output_columns`, and `self.merge_files`. Only returned if
+            `in_memory` is ``True``.
+        """
+        # set save_output_columns=True to avoid wasting time
+        if entry_list is None:
+            entry_list = self.build_entry_list(
+                tcm_level=tcm_level, save_output_columns=True
+            )
+
+        if not in_memory and output_file is None:
+            raise ValueError("if in_memory is False, need to specify an output file")
+
+        if self.output_columns is None or not self.output_columns:
+            raise ValueError("need to set output columns to load data")
+
+        if orientation == "hit":
+            self.data = self.load_hits(entry_list, in_memory, output_file, tcm_level)
+        elif orientation == "evt":
+            if tcm_level is None:
+                if len(self.tcms) == 1:
+                    tcm_level = list(self.tcms.keys())[0]
+                else:
+                    raise ValueError(
+                        "need to specify which coincidence map to use to return event-oriented data"
+                    )
+            self.data = self.load_evts(entry_list, in_memory, output_file, tcm_level)
+        else:
+            raise ValueError(
+                f"I don't understand what orientation={orientation} means!"
+            )
+
+        return self.data
+
+    def load_hits(
+        self,
+        entry_list: pd.DataFrame,
+        in_memory: bool = False,
+        output_file: str = None,
+        tcm_level: str = None,
+    ) -> None | Table | Struct | pd.DataFrame:
+        """Called by :meth:`.load` when orientation is ``hit``."""
+        if tcm_level is None:
+            parent = self.levels[0]
+            child = None
+            load_levels = [parent]
+        else:
+            parent = self.tcms[tcm_level]["parent"]
+            child = self.tcms[tcm_level]["child"]
+            load_levels = [parent, child]
+
+        def explode_evt_cols(el, tier_table):
+            # Explode columns from "evt"-style levels, untested
+            # Will only work if column has an "nda" attribute
+            cum_length = build_cl(el[f"{child}_idx"])
+            exp_cols = explode_arrays(
+                cum_length,
+                [a.nda for a in tier_table.values()],
+            )
+            tier_table.update(zip(tier_table.keys(), exp_cols))
+            return tier_table
+
+        def fill_col_dict(tier_table, col_dict, tcm_idx):
+            # Put the information from the tier_table (after the columns have been exploded)
+            # into col_dict, which will be turned into the final Table
+            for col in tier_table.keys():
+                if isinstance(tier_table[col], Array):
+                    # Allocate memory for column for all channels
+                    if col not in col_dict.keys():
+                        col_dict[col] = np.empty(
+                            table_length,
+                            dtype=tier_table[col].dtype,
+                        )
+                    col_dict[col][tcm_idx] = tier_table[col].nda
+
+                elif isinstance(tier_table[col], WaveformTable):
+                    wf_table = tier_table[col]
+                    if isinstance(
+                        wf_table["values"],
+                        ArrayOfEqualSizedArrays,
+                    ):
+                        # Allocate memory for columns for all channels
+                        if "wf_dt" not in col_dict.keys():
+                            col_dict["wf_t0"] = np.empty(
+                                table_length,
+                                dtype=wf_table["t0"].dtype,
+                            )
+                            col_dict["wf_dt"] = np.empty(
+                                table_length,
+                                dtype=wf_table["dt"].dtype,
+                            )
+                            col_dict["wf_values"] = np.empty(
+                                (
+                                    table_length,
+                                    wf_table["values"].nda.shape[1],
+                                ),
+                                dtype=wf_table["values"].dtype,
+                            )
+                        col_dict["wf_t0"][tcm_idx] = wf_table["t0"].nda
+                        col_dict["wf_dt"][tcm_idx] = wf_table["dt"].nda
+                        col_dict["wf_values"][tcm_idx] = wf_table["values"].nda
+                    else:  # wf_values is a VectorOfVectors
+                        log.warning(
+                            "not sure how to handle waveforms with values "
+                            f"of type {type(tier_table[col]['values'])} yet"
+                        )
+                else:
+                    log.warning(
+                        f"not sure how to handle column {col} "
+                        f"of type {type(tier_table[col])} yet"
+                    )
+            return col_dict
+
+        sto = LH5Store()
+
+        if self.merge_files:
+            tables = entry_list[f"{parent}_table"].unique()
+            field_mask = []
+            for col in self.output_columns:
+                if col not in entry_list.columns:
+                    field_mask.append(col)
+
+            col_tiers = self.get_tiers_for_col(field_mask)
+            col_dict = entry_list.to_dict("list")
+            table_length = len(entry_list)
+
+            for tb, level in product(tables, load_levels):
+                gb = entry_list.query(f"{parent}_table == {tb}").groupby("file")
+                files = list(gb.groups.keys())
+                el_idx = list(gb.groups.values())
+                idx_mask = [list(entry_list.loc[i, f"{level}_idx"]) for i in el_idx]
+
+                for tier in self.tiers[level]:
+                    if tb not in col_tiers[tier]:
+                        continue
+                    tb_name = self.get_table_name(tier, tb)
+                    tier_paths = [
+                        os.path.join(
+                            self.data_dir,
+                            self.filedb.tier_dirs[tier].lstrip("/"),
+                            self.filedb.df.iloc[file][f"{tier}_file"].lstrip("/"),
+                        )
+                        for file in files
+                    ]
+
+                    tier_table, _ = sto.read_object(
+                        name=tb_name,
+                        lh5_file=tier_paths,
+                        idx=idx_mask,
+                        field_mask=field_mask,
+                    )
+
+                    if level == child:
+                        explode_evt_cols(entry_list, tier_table)
+
+                    col_dict = fill_col_dict(
+                        tier_table,
+                        col_dict,
+                        [idx for idx_list in el_idx for idx in idx_list],
+                    )
+            # Convert col_dict to lgdo.Table
+            for col in col_dict.keys():
+                nda = np.array(col_dict[col])
+                col_dict[col] = Array(nda=nda)
+            f_table = Table(col_dict=col_dict)
+
+            if output_file:
+                sto.write_object(f_table, "merged_data", output_file, wo_mode="o")
+            if in_memory:
+                if self.output_format == "lgdo.Table":
+                    return f_table
+                elif self.output_format == "pd.DataFrame":
+                    return f_table.get_dataframe()
+                else:
+                    raise ValueError(
+                        f"'{self.output_format}' output format not supported"
+                    )
+        else:  # not merge_files
+            if in_memory:
+                load_out = {}
+
+            if log.getEffectiveLevel() >= logging.INFO:
+                progress_bar = tqdm(
+                    desc="Loading data",
+                    total=len(entry_list),
+                    delay=2,
+                    unit=" keys",
+                )
+
+            # now loop over the output of build_entry_list()
+            for file, f_entries in entry_list.items():
+
+                if log.getEffectiveLevel() >= logging.INFO:
+                    progress_bar.update()
+                    progress_bar.set_postfix(key=self.filedb.df.iloc[file]["timestamp"])
+
+                log.debug(
+                    f"loading data for cycle key {self.filedb.df.iloc[file]['timestamp']}"
+                )
+
+                field_mask = []
+
+                for col in self.output_columns:
+                    if col not in f_entries.columns:
+                        field_mask.append(col)
+
+                col_tiers = self.get_tiers_for_col(field_mask)
+                col_dict = f_entries.to_dict("list")
+                table_length = len(f_entries)
+
+                log.debug(f"will load new columns {field_mask}")
+
+                # loop through each table in entry list and
+                # loop through each level we're asked to load from
+                for tb, level in product(
+                    f_entries[f"{parent}_table"].unique(), load_levels
+                ):
+                    tcm_idx = f_entries.query(f"{parent}_table == {tb}").index
+                    idx_mask = f_entries.loc[tcm_idx, f"{level}_idx"].tolist()
+
+                    # loop over tiers in the level
+                    for tier in self.tiers[level]:
+
+                        if tb not in col_tiers[file]["tables"][tier]:
+                            continue
+
+                        log.debug(
+                            f"...for stream '{self.get_table_name(tier, tb)}' (at {level} level)"
+                        )
+
+                        # path to tier file
+                        tier_path = os.path.join(
+                            self.data_dir,
+                            self.filedb.tier_dirs[tier].lstrip("/"),
+                            self.filedb.df.iloc[file][f"{tier}_file"].lstrip("/"),
+                        )
+                        # this should not happen
+                        if not os.path.exists(tier_path):
+                            raise FileNotFoundError(tier_path)
+
+                        table_name = self.get_table_name(tier, tb)
+                        tier_table, _ = sto.read_object(
+                            table_name,
+                            tier_path,
+                            idx=idx_mask,
+                            field_mask=field_mask,
+                        )
+                        if level == child:
+                            explode_evt_cols(f_entries, tier_table)
+
+                        col_dict = fill_col_dict(tier_table, col_dict, tcm_idx)
+                        # end tb loop
+
+                # Convert col_dict to lgdo.Table
+                for col in col_dict.keys():
+                    nda = np.array(col_dict[col])
+                    col_dict[col] = Array(nda=nda)
+                f_table = Table(col_dict=col_dict)
+
+                if in_memory:
+                    load_out[file] = f_table
+                if output_file:
+                    sto.write_object(f_table, f"file{file}", output_file, wo_mode="o")
+                # end file loop
+
+            if log.getEffectiveLevel() >= logging.INFO:
+                progress_bar.close()
+
+            if in_memory:
+                if self.output_format == "lgdo.Table":
+                    return load_out
+                elif self.output_format == "pd.DataFrame":
+                    for file in load_out.keys():
+                        load_out[file] = load_out[file].get_dataframe()
+                    return load_out
+                else:
+                    raise ValueError(
+                        f"'{self.output_format}' output format not supported"
+                    )
+
+    def load_evts(
+        self,
+        entry_list: pd.DataFrame = None,
+        in_memory: bool = False,
+        output_file: str = None,
+        tcm_level: str = None,
+    ) -> None | Table | Struct | pd.DataFrame:
+        """Called by :meth:`load` when orientation is ``evt``."""
+        raise NotImplementedError
+
+        parent = self.tcms[tcm_level]["parent"]
+        child = self.tcms[tcm_level]["child"]
+        load_levels = [parent, child]
+
+        sto = LH5Store()
+
+        if self.merge_files:  # Try to load all information at once
+            raise NotImplementedError
+        else:  # Not merge_files
+            if in_memory:
+                load_out = {}
+            for file, f_entries in entry_list.items():
+                field_mask = []
+                f_table = None
+                # Pre-allocate memory for output columns
+                for col in self.output_columns:
+                    if col not in f_entries.columns:
+                        f_entries[col] = None
+                        field_mask.append(col)
+                col_tiers = self.get_tiers_for_col(field_mask)
+                col_dict = f_entries.to_dict("list")
+                for col in col_dict.keys():
+                    nda = np.array(col_dict[col])
+                    col_dict[col] = Array(nda=nda)
+
+                # Loop through each table in entry list
+                for tb in f_entries[f"{parent}_table"].unique():
+                    tcm_idx = f_entries.query(f"{parent}_table == {tb}").index
+                    for level in load_levels:
+                        idx_mask = f_entries.loc[tcm_idx, f"{level}_idx"]
+
+                        for tier in self.tiers[level]:
+                            if tb in col_tiers["tables"][tier]:
+                                tier_path = os.path.join(
+                                    self.data_dir,
+                                    self.filedb.tier_dirs[tier].lstrip("/"),
+                                    self.filedb.df.iloc[file][f"{tier}_file"].lstrip(
+                                        "/"
+                                    ),
+                                )
+                                if os.path.exists(tier_path):
+                                    table_name = self.get_table_name(tier, tb)
+                                    tier_table, _ = sto.read_object(
+                                        table_name,
+                                        tier_path,
+                                        idx=idx_mask,
+                                        field_mask=field_mask,
+                                    )
+                                    for col in tier_table.keys():
+                                        f_table[col].nda[tcm_idx] = tier_table[
+                                            col
+                                        ].tolist()
+                    # end tb loop
+                if in_memory:
+                    load_out[file] = f_table
+                if output_file:
+                    sto.write_object(f_table, f"file{file}", output_file, wo_mode="o")
+                # end file loop
+
+            if in_memory:
+                if self.output_format == "lgdo.Table":
+                    return load_out
+                elif self.output_format == "pd.DataFrame":
+                    for file in load_out.keys():
+                        load_out[file] = load_out[file].get_dataframe()
+                    return load_out
+                else:
+                    raise ValueError(
+                        f"'{self.output_format}' output format not supported"
+                    )
+
+    def load_detector(self, det_id):
+        """
+        special version of `load` designed to retrieve all file files, tables,
+        column names, and potentially calibration/dsp parameters relevant to one
+        single detector.
+        """
+        raise NotImplementedError
+
+    def load_settings(self):
+        """
+        get metadata stored in raw files, usually from a DAQ machine.
+        """
+        raise NotImplementedError
+
+    def load_dsp_pars(self, query):
+        """
+        access the dsp_pars parameter database (probably JSON format) and do
+        some kind of query to retrieve parameters of interest for our file list,
+        and return some tables.
+        """
+        raise NotImplementedError
+
+    def load_cal_pars(self, query):
+        """
+        access the cal_pars parameter database, run a query, and return some tables.
+        """
+        raise NotImplementedError
+
+    def skim_waveforms(self, mode: str = "hit", hit_list=None, evt_list=None):
+        """
+        handle this one separately because waveforms can easily fill up memory.
+        """
+        raise NotImplementedError
+
+    def browse(self, query, dsp_config=None):
+        """
+        Interface between DataLoader and WaveformBrowser.
+        """
+        raise NotImplementedError
+
+    # -------------- Helper Functions ----------------#
+    def get_tiers_for_col(
+        self, columns: list | np.ndarray, merge_files: bool = None
+    ) -> dict:
+        """For each column given, get the tiers and tables in that tier where
+        that column can be found.
+
+        Parameters
+        ----------
+        columns
+            the columns to look for.
+
+        Returns
+        -------
+        col_tiers
+            ``col_tiers[file]["tables"][tier]`` gives a list of tables in
+            ``tier`` that contain a column of interest.
+            ``col_tiers[file]["columns"][column]`` gives the tier that
+            ``column`` can be found in. If `self.merge_file`s then `
+            col_tiers[tier]`` is a list of tables in ``tier`` that contain a
+            column of interest.
+        """
+
+        col_tiers = {}
+
+        # filedb.columns is needed, generate it now if not available
+        if self.filedb.columns is None:
+            self.filedb.scan_tables_columns()
+
+        if merge_files is None:
+            merge_files = self.merge_files
+
+        if merge_files:
+            for file in self.file_list:
+                col_inds = set()
+                for i, col_list in enumerate(self.filedb.columns):
+                    if not set(col_list).isdisjoint(columns):
+                        col_inds.add(i)
+
+                for level in self.levels:
+                    for tier in self.tiers[level]:
+                        col_tiers[tier] = set()
+                        if self.filedb.df.loc[file, f"{tier}_col_idx"] is not None:
+                            for i in range(
+                                len(self.filedb.df.loc[file, f"{tier}_col_idx"])
+                            ):
+                                if (
+                                    self.filedb.df.loc[file, f"{tier}_col_idx"][i]
+                                    in col_inds
+                                ):
+                                    col_tiers[tier].add(
+                                        self.filedb.df.loc[file, f"{tier}_tables"][i]
+                                    )
+        else:
+            # loop over selected files (db entries)
+            for file in self.file_list:
+                # this is the output object
+                col_tiers[file] = {"tables": {}, "columns": {}}
+                # Rows of FileDB.columns that include columns that we are interested in
+                col_inds = set()
+                for i, col_list in enumerate(self.filedb.columns):
+                    if not set(list(col_list)).isdisjoint(columns):
+                        col_inds.add(i)
+
+                # Loop over tiers
+                for level in self.levels:
+                    for tier in self.tiers[level]:
+                        col_tiers[file]["tables"][tier] = []
+                        tier_col_idx = self.filedb.df.loc[file, f"{tier}_col_idx"]
+                        if tier_col_idx is not None:
+                            # Loop over tables
+                            for i in range(len(tier_col_idx)):
+                                col_idx = self.filedb.df.loc[file, f"{tier}_col_idx"][i]
+                                if col_idx in col_inds:
+                                    col_tiers[file]["tables"][tier].append(
+                                        self.filedb.df.loc[file, f"{tier}_tables"][i]
+                                    )
+                                    col_in_tier = set.intersection(
+                                        set(self.filedb.columns[col_idx]), set(columns)
+                                    )
+                                    for c in col_in_tier:
+                                        col_tiers[file]["columns"][c] = tier
+
+        return col_tiers
+
+    def get_table_name(self, tier: str, tb: str) -> str:
+        """Get the table name for a tier given its table identifier.
+
+        Parameters
+        ----------
+        tier
+            specify the tier whose table format will be used.
+        tb
+            the table identifier that will be passed to the table format.
+
+        Returns
+        -------
+        table_name
+            the name of the table in `tier` with table identifier `tb`
+        """
+        template = self.filedb.table_format[tier]
+        fm = string.Formatter()
+        parse_arr = np.array(list(fm.parse(template)))
+        names = list(parse_arr[:, 1])
+        if len(names) > 0:
+            keyword = names[0]
+            args = {keyword: tb}
+            table_name = template.format(**args)
+        else:
+            table_name = template
+        return table_name
diff --git a/src/pygama/flow/file_db.py b/src/pygama/flow/file_db.py
new file mode 100644
index 000000000..0e7408a4f
--- /dev/null
+++ b/src/pygama/flow/file_db.py
@@ -0,0 +1,535 @@
+"""
+Utilities for LH5 file inventory.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+import string
+import warnings
+from datetime import datetime, timezone
+
+import h5py
+import numpy as np
+import pandas as pd
+from parse import parse
+
+from pygama.lgdo import Array, LH5Store, Scalar, VectorOfVectors
+from pygama.lgdo.lh5_store import ls
+
+log = logging.getLogger(__name__)
+
+
+def to_datetime(key: str) -> datetime:
+    """Convert LEGEND cycle key to :class:`~datetime.datetime`.
+
+    Assumes `key` is formatted as ``YYYYMMDDTHHMMSSZ`` (UTC).
+    """
+    m = re.match(r"^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$", key)
+    if m is None:
+        raise ValueError(f"Could not parse '{key}' as a datetime object")
+    else:
+        g = [int(el) for el in m.groups()]
+        return datetime(*g, tzinfo=timezone.utc)
+
+
+def to_unixtime(key: str) -> int:
+    """Convert LEGEND cycle key to `POSIX timestamp <https://en.wikipedia.org/wiki/Unix_time>`_."""
+    return int(to_datetime(key).timestamp())
+
+
+class FileDB:
+    """LH5 file database.
+
+    A class containing a :class:`pandas.DataFrame` that has additional
+    functions to scan the data directory, fill the dataframe's columns with
+    information about each file, and read or write to disk in an LGDO format.
+
+    The database contains the following columns:
+
+    - file keys: the fields specified in the configuration file's
+      ``file_format`` that are required to generate a file name e.g. ``run``,
+      ``type``, ``timestamp`` etc.
+    - ``{tier}_file``: generated file name for the tier.
+    - ``{tier}_size``: size of file on disk, if applicable.
+    - ``file_status``: contains a bit corresponding to whether or not a file
+      for each tier exists for a given cycle e.g. If we have tiers `raw`,
+      `dsp`, and `hit`, but only the `raw` file has been produced,
+      ``file_status`` would be ``0b100``.
+    - ``{tier}_tables``: available data streams (channels) in the tier.
+    - ``{tier}_col_idx``: ``file_db.columns[{tier}_col_idx]`` will return the
+      list of columns available in the tier's file.
+
+    The database must be configured by a JSON file (or corresponding
+    dictionary), which defines the data file names, paths and LH5 layout. For
+    example:
+
+    .. code-block:: json
+
+        {
+            "data_dir": "prod-ref-l200/generated/tier",
+            "tier_dirs": {
+                "raw": "/raw",
+                "dsp": "/dsp",
+                "hit": "/hit",
+                "tcm": "/tcm",
+                "evt": "/evt"
+            },
+            "file_format": {
+                "raw": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_raw.lh5",
+                "dsp": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_dsp.lh5",
+                "hit": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_hit.lh5",
+                "evt": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_evt.lh5",
+                "tcm": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_tcm.lh5"
+            },
+            "table_format": {
+                "raw": "ch{ch:03d}/raw",
+                "dsp": "ch{ch:03d}/dsp",
+                "hit": "{ch}/hit",
+                "evt": "{grp}/evt",
+                "tcm": "hardware_tcm"
+            },
+            "tables": {
+                "raw": [0, 1, 2, 4, 5, 6, 7],
+                "dsp": [0, 1, 2, 4, 5, 6, 7],
+                "hit": [0, 1, 2, 4, 5, 6, 7],
+                "tcm": [""],
+                "evt": [""]
+            },
+            "columns": {
+                "raw": ["baseline", "waveform", "daqenergy"],
+                "dsp": ["trapEftp", "AoE", "trapEmax"],
+                "hit": ["trapEftp_cal", "trapEmax_cal"],
+                "tcm": ["cumulative_length", "array_id", "array_idx"],
+                "evt": ["lar_veto", "muon_veto", "ge_mult"]
+            }
+        }
+
+    :class:`FileDB` objects can be also stored on disk and read-in at later
+    times.
+
+    Examples
+    --------
+    >>> from pygama.flow import FileDB
+    >>> db = FileDB("./filedb_config.json")
+    >>> db.get_tables_columns()  # read in also table columns names
+    >>> print(db)
+    << Columns >>
+    [['baseline', 'card', 'ch_orca', 'channel', 'crate', 'daqenergy', 'deadtime', 'dr_maxticks', 'dr_start_pps', 'dr_start_ticks', 'dr_stop_pps', 'dr_stop_ticks', 'eventnumber', 'fcid', 'numtraces', 'packet_id', 'runtime', 'timestamp', 'to_abs_mu_usec', 'to_dt_mu_usec', 'to_master_sec', 'to_mu_sec', 'to_mu_usec', 'to_start_sec', 'to_start_usec', 'tracelist', 'ts_maxticks', 'ts_pps', 'ts_ticks', 'waveform'], ['bl_intercept', 'bl_mean', 'bl_slope', 'bl_std', 'tail_slope', 'tail_std', 'wf_blsub'], ['array_id', 'array_idx', 'cumulative_length']]
+    << DataFrame >>
+       exp period   run         timestamp type  ... hit_col_idx tcm_tables tcm_col_idx evt_tables evt_col_idx
+    0  l60    p01  r014  20220716T105236Z  cal  ...        None         []         [2]       None        None
+    1  l60    p01  r014  20220716T104550Z  cal  ...        None         []         [2]       None        None
+    >>> db.to_disk("file_db.lh5")
+    """
+
+    def __init__(self, config: str | dict, scan: bool = True) -> None:
+        """
+        Parameters
+        ----------
+        config
+            dictionary or path to JSON file specifying data directories, tiers,
+            and file name templates. Can also be path to existing LH5 file
+            containing :class:`FileDB` object serialized by :meth:`.to_disk()`.
+        scan
+            whether the file database should scan the directory containing
+            `raw` files to fill its rows with file keys.
+        """
+        config_path = None
+        if isinstance(config, str):
+            if h5py.is_hdf5(config):
+                self.from_disk(config)
+                return
+            else:
+                config_path = config
+                with open(config) as f:
+                    config = json.load(f)
+
+        if not isinstance(config, dict):
+            raise ValueError("Bad FileDB configuration value")
+
+        self.set_config(config, config_path)
+
+        # Set up column names
+        fm = string.Formatter()
+        parse_arr = np.array(list(fm.parse(self.file_format[self.tiers[0]])))
+        names = list(parse_arr[:, 1])  # fields required to generate file name
+        names = [n for n in names if n]  # Remove none values
+        names = list(np.unique(names))
+        names += [f"{tier}_file" for tier in self.tiers]  # the generated file names
+        names += [f"{tier}_size" for tier in self.tiers]  # file sizes
+        names += ["file_status"]  # bonus columns
+
+        self.df = pd.DataFrame(columns=names)
+
+        self.columns = None
+
+        if scan:
+            self.scan_files()
+            self.set_file_status()
+            self.set_file_sizes()
+
+            # Use config columns and tables if provided
+            if "columns" in self.config.keys() and "tables" in self.config.keys():
+                log.info("Setting columns/tables from config")
+                self.columns = list(self.config["columns"].values())
+                for tier in self.tiers:
+                    self.df[f"{tier}_tables"] = [self.config["tables"][tier]] * len(
+                        self.df
+                    )
+                    self.df[f"{tier}_col_idx"] = [
+                        [self.columns.index(self.config["columns"][tier])]
+                        * len(self.df[f"{tier}_tables"].iloc[0])
+                    ] * len(self.df)
+
+    def set_config(self, config: dict, config_path: str = None) -> None:
+        """Read in the configuration dictionary."""
+        self.config = config
+        self.tiers = list(self.config["tier_dirs"].keys())
+        self.file_format = self.config["file_format"]
+        self.tier_dirs = self.config["tier_dirs"]
+        self.table_format = self.config["table_format"]
+
+        # Handle environment variables
+        data_dir = os.path.expandvars(self.config["data_dir"])
+
+        # Relative paths are interpreted relative to the configuration file
+        if not data_dir.startswith("/"):
+            config_dir = os.path.dirname(config_path)
+            data_dir = os.path.join(config_dir, data_dir)
+            data_dir = os.path.abspath(data_dir)
+        self.data_dir = data_dir
+
+    def scan_files(self) -> None:
+        """Scan the directory containing files from the lower tier and fill the
+        dataframe.
+
+        The lower tier is defined as the first element of the `tiers` array.
+        Only fills columns that can be populated with just the `raw` files.
+        """
+        file_keys = []
+        n_files = 0
+        low_tier = self.tiers[0]
+        template = self.file_format[low_tier]
+        scan_dir = self.data_dir + self.tier_dirs[low_tier]
+
+        log.info(f"Scanning {scan_dir} with template {template}")
+
+        for path, _folders, files in os.walk(scan_dir):
+            log.debug(f"Scanning {path}")
+            n_files += len(files)
+
+            for f in files:
+                # in some cases, we need information from the path name
+                if "/" in template:
+                    f_tmp = path.replace(scan_dir, "") + "/" + f
+                else:
+                    f_tmp = f
+
+                finfo = parse(template, f_tmp)
+                if finfo is not None:
+                    finfo = finfo.named
+                    for tier in self.tiers:
+                        finfo[f"{tier}_file"] = self.file_format[tier].format(**finfo)
+
+                    file_keys.append(finfo)
+
+        if n_files == 0:
+            raise FileNotFoundError(f"No {low_tier} files found")
+
+        if len(file_keys) == 0:
+            raise FileNotFoundError(f"No {low_tier} files matched pattern ", template)
+
+        temp_df = pd.DataFrame(file_keys)
+
+        # fill the main DataFrame
+        self.df = pd.concat([self.df, temp_df])
+
+        # convert cols to numeric dtypes where possible
+        for col in self.df.columns:
+            self.df[col] = pd.to_numeric(self.df[col], errors="ignore")
+
+        # sort rows according to timestamps
+        log.debug("Sorting database entries according to timestamp")
+        self.df["_datetime"] = self.df["timestamp"].apply(to_datetime)
+        self.df.sort_values("_datetime", ignore_index=True, inplace=True)
+        self.df.drop("_datetime", axis=1, inplace=True)
+
+    def set_file_status(self) -> None:
+        """
+        Add a column to the dataframe with a bit corresponding to whether each
+        tier's file exists.
+
+        For example, if we have tiers `raw`, `dsp`, and `hit`, but only the
+        `raw` file has been produced, ``file_status`` would be 4 (``0b100`` in
+        binary representation).
+        """
+
+        def check_status(row):
+            status = 0
+            for i, tier in enumerate(self.tiers):
+                path_name = (
+                    self.data_dir + self.tier_dirs[tier] + "/" + row[f"{tier}_file"]
+                )
+                if os.path.exists(path_name):
+                    status |= 1 << len(self.tiers) - i - 1
+
+            return status
+
+        self.df["file_status"] = self.df.apply(check_status, axis=1)
+
+    def set_file_sizes(self) -> None:
+        """
+        Add columns (for each tier) to the database containing the
+        corresponding file size in bytes as reported by
+        :func:`os.path.getsize`.
+        """
+
+        def get_size(row, tier):
+            size = 0
+            path_name = self.data_dir + self.tier_dirs[tier] + "/" + row[f"{tier}_file"]
+            if os.path.exists(path_name):
+                size = os.path.getsize(path_name)
+            return size
+
+        for tier in self.tiers:
+            self.df[f"{tier}_size"] = self.df.apply(get_size, axis=1, tier=tier)
+
+    def scan_tables_columns(
+        self, to_file: str = None, override: bool = False
+    ) -> list[str]:
+        """Open files in the database to read (and store) available tables (and
+        columns therein) names.
+
+        Adds the available table names in each tier as a column in the
+        dataframe by searching for group names that match the configured
+        ``table_format`` and saving the associated keyword values.
+
+        Returns a list with each unique list of columns found in each table
+        and adds a column ``{tier}_col_idx`` to the dataframe that maps to the
+        column table.
+
+        Parameters
+        ----------
+        to_file:
+            Optionally write the column table to an LH5 file (as a
+            :class:`~.lgdo.vectorofvectors.VectorOfVectors`)
+
+        override:
+            If the FileDB already has a `columns` field, the scan will not run unless
+            this parameter is set to True
+        """
+        log.info("Getting table column names")
+
+        if self.columns is not None:
+            if not override:
+                log.warning(
+                    "LH5 tables/columns names already set, if you want to perform the scan anyway, set override=True"
+                )
+                return
+            else:
+                log.warning("Overwriting existing LH5 tables/columns names")
+
+        def update_tables_cols(row, tier: str) -> pd.Series:
+            fpath = self.data_dir + self.tier_dirs[tier] + "/" + row[f"{tier}_file"]
+
+            log.debug(f"Reading column names for tier '{tier}' from {fpath}")
+
+            if os.path.exists(fpath):
+                f = h5py.File(fpath)
+            else:
+                return pd.Series({f"{tier}_tables": None, f"{tier}_col_idx": None})
+
+            # Get tables in each tier
+            tier_tables = []
+            template = self.table_format[tier]
+            if template[-1] == "/":
+                template = template[:-1]
+
+            braces = list(re.finditer("{|}", template))
+
+            if len(braces) > 2:
+                raise ValueError("Tables can only have one identifier")
+            if len(braces) % 2 != 0:
+                raise ValueError("Braces mismatch in table format")
+            if len(braces) == 0:
+                tier_tables.append("")
+            else:
+                wildcard = (
+                    template[: braces[0].span()[0]]
+                    + "*"
+                    + template[braces[1].span()[1] :]
+                )
+
+                # TODO this call here is really expensive!
+                groups = ls(f, wildcard)
+                tier_tables = [
+                    list(parse(template, g).named.values())[0] for g in groups
+                ]
+
+            # Get columns
+            col_idx = []
+            template = self.table_format[tier]
+            fm = string.Formatter()
+
+            for tb in tier_tables:
+                parse_arr = np.array(list(fm.parse(template)))
+                names = list(parse_arr[:, 1])
+                if len(names) > 0:
+                    keyword = names[0]
+                    args = {keyword: tb}
+                    table_name = template.format(**args)
+                else:
+                    table_name = template
+
+                col = ls(f[table_name])
+                if col not in columns:
+                    columns.append(col)
+                    col_idx.append(len(columns) - 1)
+                else:
+                    col_idx.append(columns.index(col))
+
+            return pd.Series(
+                {f"{tier}_tables": tier_tables, f"{tier}_col_idx": col_idx}
+            )
+
+        columns = []
+        for tier in self.tiers:
+            self.df[[f"{tier}_tables", f"{tier}_col_idx"]] = self.df.apply(
+                update_tables_cols, axis=1, tier=tier
+            )
+
+        self.columns = columns
+
+        if to_file is not None:
+            log.debug(f"Writing column names to '{to_file}'")
+            flattened = []
+            length = []
+            for i, col in enumerate(columns):
+                if i == 0:
+                    length.append(len(col))
+                else:
+                    length.append(length[i - 1] + len(col))
+                for c in col:
+                    flattened.append(c)
+            columns_vov = VectorOfVectors(
+                flattened_data=flattened, cumulative_length=length
+            )
+            sto = LH5Store()
+            sto.write_object(columns_vov, "unique_columns", to_file)
+
+        return columns
+
+    def from_disk(self, filename: str) -> None:
+        """
+        Fills the dataframe (and configuration dictionary) with the information
+        from a file created by :meth:`to_disk`.
+        """
+        sto = LH5Store()
+        cfg, _ = sto.read_object("config", filename)
+        self.set_config(json.loads(cfg.value.decode()))
+
+        self.df = pd.read_hdf(filename, key="dataframe")
+
+        vov, _ = sto.read_object("columns", filename)
+        # Convert back from VoV of UTF-8 bytestrings to a list of lists of strings
+        vov = list(vov)
+        columns = []
+        for ov in vov:
+            columns.append([v.decode("utf-8") for v in ov])
+        self.columns = columns
+
+    def to_disk(self, filename: str, wo_mode="write_safe") -> None:
+        """Serializes database to disk.
+
+        Parameters
+        -----------
+        filename
+            output LH5 file name.
+        wo_mode
+            passed to :meth:`~.lgdo.lh5_store.write_object`.
+        """
+        log.debug(f"Writing database to {filename}")
+
+        sto = LH5Store()
+        sto.write_object(
+            Scalar(json.dumps(self.config)), "config", filename, wo_mode=wo_mode
+        )
+
+        if wo_mode in ["write_safe", "w", "overwrite_file", "of"]:
+            wo_mode = "a"
+
+        if self.columns is not None:
+            flat = []
+            cum_l = [0]
+            for i in range(len(self.columns)):
+                flat += self.columns[i]
+                cum_l.append(cum_l[i] + len(self.columns[i]))
+            cum_l = cum_l[1:]
+            # Must use type 'S' to play nice with HDF
+            col_vov = VectorOfVectors(
+                flattened_data=Array(nda=np.array(flat).astype("S")),
+                cumulative_length=Array(nda=np.array(cum_l)),
+            )
+            sto.write_object(col_vov, "columns", filename, wo_mode=wo_mode)
+
+        # FIXME: to_hdf() throws this:
+        #
+        #     pandas.errors.PerformanceWarning: your performance may suffer as
+        #     PyTables will pickle object types that it cannot map directly to c-types
+        #
+        # not sure how to fix this so we ignore the warning for the moment
+        warnings.simplefilter(action="ignore", category=pd.errors.PerformanceWarning)
+        self.df.to_hdf(filename, key="dataframe", format="fixed", mode="r+")
+
+    def scan_daq_files(self, daq_dir: str, daq_template: str) -> None:
+        """
+        Does the exact same thing as :meth:`.scan_files` but with extra
+        configuration arguments for a DAQ directory and template instead of
+        using the lowest tier.
+        """
+        file_keys = []
+        n_files = 0
+
+        for path, _folders, files in os.walk(daq_dir):
+            n_files += len(files)
+
+            for f in files:
+
+                # in some cases, we need information from the path name
+                if "/" in daq_template:
+                    f_tmp = path.replace(daq_dir, "") + "/" + f
+                else:
+                    f_tmp = f
+
+                finfo = parse(daq_template, f_tmp)
+                if finfo is not None:
+                    finfo = finfo.named
+                    file_keys.append(finfo)
+                for tier in self.tiers:
+                    finfo[f"{tier}_file"] = self.file_format[tier].format(**finfo)
+
+        if n_files == 0:
+            raise FileNotFoundError("No DAQ files found")
+
+        if len(file_keys) == 0:
+            raise FileNotFoundError("No DAQ files matched pattern ", daq_template)
+
+        temp_df = pd.DataFrame(file_keys)
+
+        # fill the main DataFrame
+        self.df = pd.concat([self.df, temp_df])
+
+        # convert cols to numeric dtypes where possible
+        for col in self.df.columns:
+            self.df[col] = pd.to_numeric(self.df[col], errors="ignore")
+
+    def __repr__(self) -> str:
+        string = (
+            "<< Columns >>\n" + self.columns.__repr__() + "\n"
+            "\n"
+            "<< DataFrame >>\n" + self.df.__repr__()
+        )
+        return string
diff --git a/src/pygama/hit/__init__.py b/src/pygama/hit/__init__.py
index af21b481f..c49319841 100644
--- a/src/pygama/hit/__init__.py
+++ b/src/pygama/hit/__init__.py
@@ -1,3 +1,8 @@
 """
-Subpackage description.
+Routines for applying columnar transformations to tabular data. Specifically,
+to produce the hit-tier from the dsp-tier.
 """
+
+from pygama.hit.build_hit import build_hit
+
+__all__ = ["build_hit"]
diff --git a/src/pygama/hit/build_hit.py b/src/pygama/hit/build_hit.py
index fdffa2a0f..7f6e89fac 100644
--- a/src/pygama/hit/build_hit.py
+++ b/src/pygama/hit/build_hit.py
@@ -1,1 +1,159 @@
-# placeholder
+"""
+This module implements routines to evaluate expressions to columnar data.
+"""
+from __future__ import annotations
+
+import json
+import logging
+import os
+from collections import OrderedDict
+
+import numpy as np
+
+from pygama.lgdo import LH5Iterator, LH5Store, ls
+
+log = logging.getLogger(__name__)
+
+
+def build_hit(
+    infile: str,
+    outfile: str = None,
+    hit_config: str | dict = None,
+    lh5_tables: list[str] = None,
+    lh5_tables_config: str | dict[str] = None,
+    n_max: int = np.inf,
+    wo_mode: str = "write_safe",
+    buffer_len: int = 3200,
+) -> None:
+    """
+    Transform a :class:`~.lgdo.Table` into a new :class:`~.lgdo.Table` by
+    evaluating strings describing column operations.
+
+    Operates on columns only, not specific rows or elements.
+
+    Parameters
+    ----------
+    infile
+        input LH5 file name containing tables to be processed.
+    outfile
+        name of the output LH5 file. If ``None``, create a file in the same
+        directory and append `_hit` to its name.
+    hit_config
+        dictionary or name of JSON file defining column transformations. Must
+        contain an ``outputs`` and an ``operations`` keys. For example:
+
+        .. code-block:: json
+
+            {
+                "outputs": ["calE", "AoE"],
+                "operations": {
+                    "calE": {
+                        "expression": "sqrt(@a + @b * trapEmax**2)",
+                        "parameters": {"a": "1.23", "b": "42.69"},
+                    },
+                    "AoE": {"expression": "A_max/calE"},
+                }
+            }
+
+        The ``outputs`` array lists columns that will be effectively written in
+        the output LH5 file. Add here columns that will be simply forwarded as
+        they are from the DSP tier.
+
+    lh5_tables
+        tables to consider in the input file. if ``None``, tables with name
+        `dsp` will be searched for in the file, even nested by one level.
+    lh5_tables_config
+        dictionary or JSON file defining the mapping between LH5 tables in
+        `infile` and hit configuration. Table names can be directly mapped to
+        configuration blocks or to JSON files containing them. This option is
+        mutually exclusive with `hit_config` and `lh5_tables`.
+    n_max
+        maximum number of rows to process
+    wo_mode
+        forwarded to :meth:`~.lgdo.lh5_store.write_object`.
+    """
+    store = LH5Store()
+
+    if lh5_tables_config is None and hit_config is None:
+        raise ValueError("either lh5_tables_config or hit_config must be specified")
+
+    if lh5_tables_config is not None and (
+        hit_config is not None or lh5_tables is not None
+    ):
+        raise ValueError(
+            "lh5_tables_config and hit_config/lh5_tables options are mutually exclusive"
+        )
+
+    if lh5_tables_config is not None:
+        tbl_cfg = lh5_tables_config
+        # sanitize config
+        if isinstance(tbl_cfg, str):
+            with open(tbl_cfg) as f:
+                tbl_cfg = json.load(f)
+
+        for (k, v) in tbl_cfg.items():
+            if isinstance(v, str):
+                with open(v) as f:
+                    # order in hit configs is important (dependencies)
+                    tbl_cfg[k] = json.load(f, object_pairs_hook=OrderedDict)
+        lh5_tables_config = tbl_cfg
+
+    else:
+        if isinstance(hit_config, str):
+            # sanitize config
+            with open(hit_config) as f:
+                # order in hit configs is important (dependencies)
+                hit_config = json.load(f, object_pairs_hook=OrderedDict)
+
+        if lh5_tables is None:
+            lh5_tables_config = {}
+            if "dsp" in ls(infile):
+                log.debug("found candidate table /dsp")
+                lh5_tables_config["dsp"] = hit_config
+            for el in ls(infile):
+                if f"{el}/dsp" in ls(infile, f"{el}/"):
+                    log.debug(f"found candidate table /{el}/dsp")
+                    lh5_tables_config[f"{el}/dsp"] = hit_config
+
+    if outfile is None:
+        outfile = os.path.splitext(os.path.basename(infile))[0]
+        outfile = outfile.removesuffix("_dsp") + "_hit.lh5"
+
+    first_done = False
+    for (tbl, cfg) in lh5_tables_config.items():
+        lh5_it = LH5Iterator(infile, tbl, buffer_len=buffer_len)
+        tot_n_rows = store.read_n_rows(tbl, infile)
+        write_offset = 0
+
+        log.info(f"Processing table '{tbl}' in file {infile}")
+
+        for tbl_obj, start_row, n_rows in lh5_it:
+            n_rows = min(tot_n_rows - start_row, n_rows)
+
+            outtbl_obj = tbl_obj.eval(cfg["operations"])
+
+            # remove or add columns according to "outputs" in the configuration
+            # dictionary
+            if "outputs" in cfg:
+                if isinstance(cfg["outputs"], list):
+                    # add missing columns (forwarding)
+                    for out in cfg["outputs"]:
+                        if out not in outtbl_obj.keys():
+                            outtbl_obj.add_column(out, tbl_obj[out])
+
+                    # remove non-required columns
+                    existing_cols = list(outtbl_obj.keys())
+                    for col in existing_cols:
+                        if col not in cfg["outputs"]:
+                            outtbl_obj.remove_column(col, delete=True)
+
+            store.write_object(
+                obj=outtbl_obj,
+                name=tbl.replace("/dsp", "/hit"),
+                lh5_file=outfile,
+                n_rows=n_rows,
+                wo_mode=wo_mode if first_done is False else "append",
+                write_start=write_offset + start_row,
+            )
+
+            first_done = True
diff --git a/src/pygama/lgdo/__init__.py b/src/pygama/lgdo/__init__.py
index 426188232..75ad26e4c 100644
--- a/src/pygama/lgdo/__init__.py
+++ b/src/pygama/lgdo/__init__.py
@@ -32,7 +32,7 @@
 from pygama.lgdo.array import Array
 from pygama.lgdo.arrayofequalsizedarrays import ArrayOfEqualSizedArrays
 from pygama.lgdo.fixedsizearray import FixedSizeArray
-from pygama.lgdo.lh5_store import LH5Store, load_dfs, load_nda, ls
+from pygama.lgdo.lh5_store import LH5Iterator, LH5Store, load_dfs, load_nda, ls, show
 from pygama.lgdo.scalar import Scalar
 from pygama.lgdo.struct import Struct
 from pygama.lgdo.table import Table
@@ -54,12 +54,18 @@
     "Table",
     "VectorOfVectors",
     "WaveformTable",
+    "LH5Iterator",
     "LH5Store",
     "load_dfs",
     "load_nda",
     "ls",
+    "show",
     "build_cl",
     "explode",
     "explode_arrays",
     "explode_cl",
 ]
+
+import numpy as np
+
+np.set_printoptions(threshold=10)
diff --git a/src/pygama/lgdo/array.py b/src/pygama/lgdo/array.py
index e4c5ccc73..a9deb4a80 100644
--- a/src/pygama/lgdo/array.py
+++ b/src/pygama/lgdo/array.py
@@ -104,11 +104,16 @@ def resize(self, new_size: int) -> None:
 
     def __str__(self) -> str:
         tmp_attrs = self.attrs.copy()
-        datatype = tmp_attrs.pop("datatype")
-        string = datatype + " = " + str(self.nda)
+        tmp_attrs.pop("datatype")
+        string = str(self.nda)
         if len(tmp_attrs) > 0:
-            string += "\n attrs = " + str(tmp_attrs)
+            string += f" with attrs={tmp_attrs}"
         return string
 
     def __repr__(self) -> str:
-        return str(self)
+        return (
+            self.__class__.__name__
+            + "("
+            + np.array2string(self.nda, prefix=self.__class__.__name__ + " ")
+            + f", attrs={repr(self.attrs)})"
+        )
diff --git a/src/pygama/lgdo/arrayofequalsizedarrays.py b/src/pygama/lgdo/arrayofequalsizedarrays.py
index 3cab5af72..06b1e7e0f 100644
--- a/src/pygama/lgdo/arrayofequalsizedarrays.py
+++ b/src/pygama/lgdo/arrayofequalsizedarrays.py
@@ -35,15 +35,16 @@ def __init__(
             specifies the dimensions required for building the
             :class:`ArrayOfEqualSizedArrays`' `datatype` attribute.
         nda
-            An ndarray to be used for this object's internal array. Note: the
-            array is used directly, not copied. If not supplied, internal memory
-            is newly allocated based on the shape and dtype arguments.
+            An :class:`numpy.ndarray` to be used for this object's internal
+            array. Note: the array is used directly, not copied. If not
+            supplied, internal memory is newly allocated based on the `shape`
+            and `dtype` arguments.
         shape
-            A numpy-format shape specification for shape of the internal
-            ndarray. Required if nda is None, otherwise unused.
+            A NumPy-format shape specification for shape of the internal
+            array. Required if `nda` is ``None``, otherwise unused.
         dtype
-            Specifies the type of the data in the array. Required if nda is
-            None, otherwise unused.
+            Specifies the type of the data in the array. Required if `nda` is
+            ``None``, otherwise unused.
         fill_val
             If ``None``, memory is allocated without initialization. Otherwise,
             the array is allocated with all elements set to the corresponding
diff --git a/src/pygama/lgdo/lgdo_utils.py b/src/pygama/lgdo/lgdo_utils.py
index 669b6e300..c53662ca1 100644
--- a/src/pygama/lgdo/lgdo_utils.py
+++ b/src/pygama/lgdo/lgdo_utils.py
@@ -3,7 +3,9 @@
 """
 from __future__ import annotations
 
+import glob
 import logging
+import os
 
 import numpy as np
 
@@ -86,3 +88,32 @@ def parse_datatype(datatype: str) -> tuple[str, tuple[int, ...], str | list[str]
         return datatype, tuple(dims), element_description
     else:
         return datatype, None, element_description.split(",")
+
+
+def expand_path(path: str, list: bool = False) -> str | list:
+    """Expand environment variables and wildcards to return absolute path
+
+    Parameters
+    ----------
+    path
+        name of path, which may include environment variables and wildcards
+    list
+        if True, return a list. If False, return a string; if False and a
+        unique file is not found, raise an Exception
+
+    Returns
+    -------
+    path or list of paths
+        Unique absolute path, or list of all absolute paths
+    """
+
+    paths = glob.glob(os.path.expanduser(os.path.expandvars(path)))
+    if not list:
+        if len(paths) == 0:
+            raise FileNotFoundError(f"could not find path matching {path}")
+        elif len(paths) > 1:
+            raise FileNotFoundError(f"found multiple paths matching {path}")
+        else:
+            return paths[0]
+    else:
+        return paths
diff --git a/src/pygama/lgdo/lh5_store.py b/src/pygama/lgdo/lh5_store.py
index 369cf683e..55745df9e 100644
--- a/src/pygama/lgdo/lh5_store.py
+++ b/src/pygama/lgdo/lh5_store.py
@@ -14,13 +14,14 @@
 from typing import Any, Union
 
 import h5py
+import numba as nb
 import numpy as np
 import pandas as pd
 
 from pygama.lgdo.array import Array
 from pygama.lgdo.arrayofequalsizedarrays import ArrayOfEqualSizedArrays
 from pygama.lgdo.fixedsizearray import FixedSizeArray
-from pygama.lgdo.lgdo_utils import parse_datatype
+from pygama.lgdo.lgdo_utils import expand_path, parse_datatype
 from pygama.lgdo.scalar import Scalar
 from pygama.lgdo.struct import Struct
 from pygama.lgdo.table import Table
@@ -56,7 +57,7 @@ def __init__(self, base_path: str = "", keep_open: bool = False) -> None:
             whether to keep files open by storing the :mod:`h5py` objects as
             class attributes.
         """
-        self.base_path = base_path
+        self.base_path = "" if base_path == "" else expand_path(base_path)
         self.keep_open = keep_open
         self.files = {}
 
@@ -72,6 +73,8 @@ def gimme_file(self, lh5_file: str | h5py.File, mode: str = "r") -> h5py.File:
         """
         if isinstance(lh5_file, h5py.File):
             return lh5_file
+        if mode == "r":
+            lh5_file = expand_path(lh5_file)
         if lh5_file in self.files.keys():
             return self.files[lh5_file]
         if self.base_path != "":
@@ -257,7 +260,10 @@ def read_object(
             return obj_buf, n_rows_read
 
         # start read from single file. fail if the object is not found
-        log.debug(f"reading '{name}' from {lh5_file}")
+        log.debug(
+            f"reading '{name}' from {lh5_file}"
+            + (f" with field mask {field_mask}" if field_mask else "")
+        )
 
         # get the file from the store
         h5f = self.gimme_file(lh5_file, "r")
@@ -285,7 +291,7 @@ def read_object(
             elif isinstance(field_mask, dict):
                 default = True
                 if len(field_mask) > 0:
-                    default = not field_mask[field_mask.keys[0]]
+                    default = not field_mask[list(field_mask.keys())[0]]
                 field_mask = defaultdict(lambda: default, field_mask)
             elif isinstance(field_mask, (list, tuple)):
                 field_mask = defaultdict(
@@ -381,7 +387,12 @@ def read_object(
                     obj_buf.resize(obj_buf_start + n_rows_read)
                 rows_read.append(n_rows_read)
             # warn if all columns don't read in the same number of rows
-            n_rows_read = rows_read[0]
+            if len(rows_read) > 0:
+                n_rows_read = rows_read[0]
+            else:
+                n_rows_read = 0
+                log.warning(f"Table '{name}' has no subgroups accepted by field mask")
+
             for n in rows_read[1:]:
                 if n != n_rows_read:
                     log.warning(
@@ -436,14 +447,6 @@ def read_object(
             if obj_buf is not None and not isinstance(obj_buf, VectorOfVectors):
                 raise ValueError(f"obj_buf for '{name}' not a LGDO VectorOfVectors")
 
-            if idx is not None:
-                raise NotImplementedError(
-                    "fancy indexed readout not implemented for VectorOfVectors"
-                )
-                # TODO: implement idx: first pull out all of cumulative length,
-                # use it to build an idx for the data_array, then rebuild
-                # cumulative length
-
             # read out cumulative_length
             cumulen_buf = None if obj_buf is None else obj_buf.cumulative_length
             cumulative_length, n_rows_read = self.read_object(
@@ -451,6 +454,7 @@ def read_object(
                 h5f,
                 start_row=start_row,
                 n_rows=n_rows,
+                idx=idx,
                 obj_buf=cumulen_buf,
                 obj_buf_start=obj_buf_start,
             )
@@ -459,65 +463,106 @@ def read_object(
                 obj_buf_start : obj_buf_start + n_rows_read
             ]
 
-            # determine the start_row and n_rows for the flattened_data readout
-            da_start = 0
-            if start_row > 0 and n_rows_read > 0:
-                # need to read out the cumulen sample -before- the first sample
-                # read above in order to get the starting row of the first
-                # vector to read out in flattened_data
-                da_start = h5f[f"{name}/cumulative_length"][start_row - 1]
-
-                # check limits for values that will be used subsequently
-                if this_cumulen_nda[-1] < da_start:
-                    log.debug(
-                        f"this_cumulen_nda[-1] = {this_cumulen_nda[-1]}, "
-                        f"da_start = {da_start}, "
-                        f"start_row = {start_row}, "
-                        f"n_rows_read = {n_rows_read}"
-                    )
-                    raise RuntimeError(
-                        f"cumulative_length non-increasing between entries "
-                        f"{start_row} and {start_row+n_rows_read} ??"
-                    )
+            if idx is not None and n_rows_read > 0:
+                # get the starting indices for each array in flattended data:
+                # the starting index for array[i] is cumulative_length[i-1]
+                idx2 = (np.asarray(idx[0]).copy() - 1,)
+                # re-read cumulative_length with these indices
+                # note this will allocate memory for fd_starts!
+                fd_start = None
+                if idx2[0][0] == -1:
+                    idx2 = (idx2[0][1:],)
+                    fd_start = 0  # this variable avoids an ndarray append
+                fd_starts, fds_n_rows_read = self.read_object(
+                    f"{name}/cumulative_length",
+                    h5f,
+                    start_row=start_row,
+                    n_rows=n_rows,
+                    idx=idx2,
+                )
+                fd_starts = fd_starts.nda  # we just need the nda
+                if fd_start is None:
+                    fd_start = fd_starts[0]
+
+                # compute the length that flattened_data will have after the
+                # fancy-indexed read
+                fd_n_rows = np.sum(this_cumulen_nda[-len(fd_starts) :] - fd_starts)
+                if fd_start == 0:
+                    fd_n_rows += this_cumulen_nda[0]
+
+                # now make fd_idx
+                fd_idx = np.empty(fd_n_rows, dtype="uint32")
+                fd_idx = _make_fd_idx(fd_starts, this_cumulen_nda, fd_idx)
+
+                # Now clean up this_cumulen_nda, to be ready
+                # to match the in-memory version of flattened_data. Note: these
+                # operations on the view change the original array because they are
+                # numpy arrays, not lists.
+                this_cumulen_nda[-len(fd_starts) :] -= fd_starts
+                np.cumsum(this_cumulen_nda, out=this_cumulen_nda)
 
-            # determine the number of rows for the flattened_data readout
-            da_nrows = this_cumulen_nda[-1] if n_rows_read > 0 else 0
+            else:
+                fd_idx = None
+
+                # determine the start_row and n_rows for the flattened_data readout
+                fd_start = 0
+                if start_row > 0 and n_rows_read > 0:
+                    # need to read out the cumulen sample -before- the first sample
+                    # read above in order to get the starting row of the first
+                    # vector to read out in flattened_data
+                    fd_start = h5f[f"{name}/cumulative_length"][start_row - 1]
+
+                    # check limits for values that will be used subsequently
+                    if this_cumulen_nda[-1] < fd_start:
+                        log.debug(
+                            f"this_cumulen_nda[-1] = {this_cumulen_nda[-1]}, "
+                            f"fd_start = {fd_start}, "
+                            f"start_row = {start_row}, "
+                            f"n_rows_read = {n_rows_read}"
+                        )
+                        raise RuntimeError(
+                            f"cumulative_length non-increasing between entries "
+                            f"{start_row} and {start_row+n_rows_read} ??"
+                        )
 
-            # Now done with this_cumulen_nda, so we can clean it up to be ready
-            # to match the in-memory version of flattened_data. Note: these
-            # operations on the view change the original array because they are
-            # numpy arrays, not lists.
-            #
-            # First we need to subtract off the in-file offset for the start of
-            # read for flattened_data
-            this_cumulen_nda -= da_start
+                # determine the number of rows for the flattened_data readout
+                fd_n_rows = this_cumulen_nda[-1] if n_rows_read > 0 else 0
 
-            # Then, if we started with a partially-filled buffer, add the
+                # Now done with this_cumulen_nda, so we can clean it up to be ready
+                # to match the in-memory version of flattened_data. Note: these
+                # operations on the view change the original array because they are
+                # numpy arrays, not lists.
+                #
+                # First we need to subtract off the in-file offset for the start of
+                # read for flattened_data
+                this_cumulen_nda -= fd_start
+
+            # If we started with a partially-filled buffer, add the
             # appropriate offset for the start of the in-memory flattened
             # data for this read.
-            da_buf_start = 0
+            fd_buf_start = np.uint32(0)
             if obj_buf_start > 0:
-                da_buf_start = cumulative_length.nda[obj_buf_start - 1]
-                this_cumulen_nda += da_buf_start
+                fd_buf_start = cumulative_length.nda[obj_buf_start - 1]
+                this_cumulen_nda += fd_buf_start
 
             # Now prepare the object buffer if necessary
-            da_buf = None
+            fd_buf = None
             if obj_buf is not None:
-                da_buf = obj_buf.flattened_data
-                # grow da_buf if necessary to hold the data
-                dab_size = da_buf_start + da_nrows
-                if len(da_buf) < dab_size:
-                    da_buf.resize(dab_size)
+                fd_buf = obj_buf.flattened_data
+                # grow fd_buf if necessary to hold the data
+                fdb_size = fd_buf_start + fd_n_rows
+                if len(fd_buf) < fdb_size:
+                    fd_buf.resize(fdb_size)
 
             # now read
             flattened_data, dummy_rows_read = self.read_object(
                 f"{name}/flattened_data",
                 h5f,
-                start_row=da_start,
-                n_rows=da_nrows,
-                idx=idx,
-                obj_buf=da_buf,
-                obj_buf_start=da_buf_start,
+                start_row=fd_start,
+                n_rows=fd_n_rows,
+                idx=fd_idx,
+                obj_buf=fd_buf,
+                obj_buf_start=fd_buf_start,
             )
             if obj_buf is not None:
                 return obj_buf, n_rows_read
@@ -575,6 +620,7 @@ def read_object(
                 # have to apply this patch to h5py (or update h5py, if it's
                 # fixed): https://github.com/h5py/h5py/issues/1792
                 h5f[name].read_direct(obj_buf.nda, source_sel, dest_sel)
+                nda = obj_buf.nda
             else:
                 if n_rows == 0:
                     tmp_shape = (0,) + h5f[name].shape[1:]
@@ -728,9 +774,31 @@ def write_object(
                     write_start = len_cl
                 if len_cl > 0:
                     offset = group["cumulative_length"][write_start - 1]
+
+            # First write flattened_data array. Only write rows with data.
+            fd_start = 0 if start_row == 0 else obj.cumulative_length.nda[start_row - 1]
+            fd_n_rows = obj.cumulative_length.nda[start_row + n_rows - 1] - fd_start
+            self.write_object(
+                obj.flattened_data,
+                "flattened_data",
+                lh5_file,
+                group=group,
+                start_row=fd_start,
+                n_rows=fd_n_rows,
+                wo_mode=wo_mode,
+                write_start=offset,
+            )
+
+            # now offset is used to give appropriate in-file values for
+            # cumulative_length. Need to adjust it for start_row
+            if start_row > 0:
+                offset -= obj.cumulative_length.nda[start_row - 1]
+
             # Add offset to obj.cumulative_length itself to avoid memory allocation.
-            # Then subtract it off after writing!
+            # Then subtract it off after writing! (otherwise it will be changed
+            # upon return)
             obj.cumulative_length.nda += offset
+
             self.write_object(
                 obj.cumulative_length,
                 "cumulative_length",
@@ -743,19 +811,6 @@ def write_object(
             )
             obj.cumulative_length.nda -= offset
 
-            # now write data array. Only write rows with data.
-            da_start = 0 if start_row == 0 else obj.cumulative_length.nda[start_row - 1]
-            da_n_rows = obj.cumulative_length.nda[n_rows - 1] - da_start
-            self.write_object(
-                obj.flattened_data,
-                "flattened_data",
-                lh5_file,
-                group=group,
-                start_row=da_start,
-                n_rows=da_n_rows,
-                wo_mode=wo_mode,
-                write_start=offset,
-            )
             return
 
         # if we get this far, must be one of the Array types
@@ -846,10 +901,11 @@ def read_n_rows(self, name: str, lh5_file: str | h5py.File) -> int | None:
         raise RuntimeError(f"don't know how to read datatype '{datatype}'")
 
 
-def ls(lh5_file: str, lh5_group: str = "") -> list[str]:
+def ls(lh5_file: str | h5py.Group, lh5_group: str = "") -> list[str]:
     """Return a list of LH5 groups in the input file and group, similar
     to ``ls`` or ``h5ls``. Supports wildcards in group names.
 
+
     Parameters
     ----------
     lh5_file
@@ -859,6 +915,11 @@ def ls(lh5_file: str, lh5_group: str = "") -> list[str]:
         list all objects inside that group.
     """
 
+    log.debug(
+        f"Listing objects in '{lh5_file}'"
+        + ("" if lh5_group == "" else f" (and group {lh5_group})")
+    )
+
     lh5_st = LH5Store()
     # To use recursively, make lh5_file a h5group instead of a string
     if isinstance(lh5_file, str):
@@ -881,6 +942,92 @@ def ls(lh5_file: str, lh5_group: str = "") -> list[str]:
         return ret
 
 
+def show(
+    lh5_file: str | h5py.Group,
+    lh5_group: str = "/",
+    indent: str = "",
+    header: bool = True,
+) -> None:
+    """Print a tree of LH5 file contents with LGDO datatype.
+
+    Parameters
+    ----------
+    lh5_file
+        the LH5 file.
+    lh5_group
+        print only contents of this HDF5 group.
+    indent
+        indent the diagram with this string.
+    header
+        print `lh5_group` at the top of the diagram.
+
+    Examples
+    --------
+    >>> from pygama.lgdo import show
+    >>> show("file.lh5", "/geds/raw")
+    /geds/raw
+    ├── channel · array<1>{real}
+    ├── energy · array<1>{real}
+    ├── timestamp · array<1>{real}
+    ├── waveform · table{t0,dt,values}
+    │   ├── dt · array<1>{real}
+    │   ├── t0 · array<1>{real}
+    │   └── values · array_of_equalsized_arrays<1,1>{real}
+    └── wf_std · array<1>{real}
+    """
+    # open file
+    if isinstance(lh5_file, str):
+        lh5_file = h5py.File(expand_path(lh5_file), "r")
+
+    # go to group
+    if lh5_group != "/":
+        lh5_file = lh5_file[lh5_group]
+
+    if header:
+        print(f"\033[1m{lh5_group}\033[0m")  # noqa: T201
+
+    # get an iterator over the keys in the group
+    it = iter(lh5_file)
+    key = None
+
+    # make sure there is actually something in this file/group
+    try:
+        key = next(it)  # get first key
+    except StopIteration:
+        print(f"{indent}└──  empty")  # noqa: T201
+        return
+
+    # loop over keys
+    while True:
+        val = lh5_file[key]
+        # we want to print the LGDO datatype
+        attr = val.attrs.get("datatype", default="no datatype")
+        if attr == "no datatype" and isinstance(val, h5py.Group):
+            attr = "HDF5 group"
+
+        # is this the last key?
+        killme = False
+        try:
+            k_new = next(it)  # get next key
+        except StopIteration:
+            char = "└──"
+            killme = True  # we'll have to kill this loop later
+        else:
+            char = "├──"
+
+        print(f"{indent}{char} \033[1m{key}\033[0m · {attr}")  # noqa: T201
+
+        # if it's a group, call this function recursively
+        if isinstance(val, h5py.Group):
+            show(val, indent=indent + ("    " if killme else "│   "), header=False)
+
+        # break or move to next key
+        if killme:
+            break
+        else:
+            key = k_new
+
+
 def load_nda(
     f_list: str | list[str],
     par_list: list[str],
@@ -1033,10 +1180,17 @@ def __init__(
 
         # List of files, with wildcards and env vars expanded
         if isinstance(lh5_files, str):
-            lh5_files = [lh5_files]
-        self.lh5_files = [
-            f for f_wc in lh5_files for f in sorted(glob.glob(os.path.expandvars(f_wc)))
-        ]
+            lh5_files = expand_path(lh5_files, True)
+        elif not isinstance(lh5_files, list):
+            raise ValueError("lh5_files must be a string or list of strings")
+
+        if entry_list is not None and entry_mask is not None:
+            raise ValueError(
+                "entry_list and entry_mask arguments are mutually exclusive"
+            )
+
+        self.lh5_files = [f for f_wc in lh5_files for f in expand_path(f_wc, True)]
+
         # Map to last row in each file
         self.file_map = np.array(
             [self.lh5_st.read_n_rows(group, f) for f in self.lh5_files], "int64"
@@ -1052,7 +1206,7 @@ def __init__(
                 field_mask=field_mask,
             )
         else:
-            None
+            raise RuntimeError(f"can't open any files from {lh5_files}")
 
         self.n_rows = 0
         self.current_entry = 0
@@ -1143,3 +1297,18 @@ def __iter__(self) -> tuple[LGDO, int, int]:
             buf, n_rows = self.read(entry)
             yield (buf, entry, n_rows)
             entry += n_rows
+
+
+@nb.njit(parallel=False, fastmath=True)
+def _make_fd_idx(starts, stops, idx):
+    k = 0
+    if len(starts) < len(stops):
+        for i in range(stops[0]):
+            idx[k] = i
+            k += 1
+        stops = stops[1:]
+    for j in range(len(starts)):
+        for i in range(starts[j], stops[j]):
+            idx[k] = i
+            k += 1
+    return (idx,)
diff --git a/src/pygama/lgdo/scalar.py b/src/pygama/lgdo/scalar.py
index 8c75066eb..b1f71807c 100644
--- a/src/pygama/lgdo/scalar.py
+++ b/src/pygama/lgdo/scalar.py
@@ -53,13 +53,12 @@ def form_datatype(self) -> str:
         return self.datatype_name()
 
     def __str__(self) -> str:
-        """Convert to string (e.g. for printing)."""
-        string = str(self.value)
         tmp_attrs = self.attrs.copy()
         tmp_attrs.pop("datatype")
-        if len(tmp_attrs) > 0:
-            string += "\n" + str(tmp_attrs)
-        return string
+        return f"{str(self.value)} with attrs={repr(tmp_attrs)}"
 
     def __repr__(self) -> str:
-        return str(self)
+        return (
+            self.__class__.__name__
+            + f"(value={repr(self.value)}, attrs={repr(self.attrs)})"
+        )
diff --git a/src/pygama/lgdo/struct.py b/src/pygama/lgdo/struct.py
index 553aa8016..14d55145e 100644
--- a/src/pygama/lgdo/struct.py
+++ b/src/pygama/lgdo/struct.py
@@ -7,6 +7,8 @@
 import logging
 from typing import Any, Union
 
+import numpy as np
+
 from pygama.lgdo.array import Array
 from pygama.lgdo.scalar import Scalar
 from pygama.lgdo.vectorofvectors import VectorOfVectors
@@ -64,22 +66,62 @@ def update_datatype(self) -> None:
         self.attrs["datatype"] = self.form_datatype()
 
     def add_field(self, name: str, obj: LGDO | Struct) -> None:
+        """Add a field to the table."""
         self[name] = obj
         self.update_datatype()
 
+    def remove_field(self, name: str, delete: bool = False) -> None:
+        """Remove a field from the table.
+
+        Parameters
+        ----------
+        name
+            name of the field to be removed
+        delete
+            if ``True``, delete the field object by calling :any:`del`.
+        """
+        if delete:
+            del self[name]
+        else:
+            self.pop(name)
+        self.update_datatype()
+
     def __len__(self) -> int:
         """As a convention for Structs, always return 1."""
         return 1
 
     def __str__(self) -> str:
         """Convert to string (e.g. for printing)."""
+
+        thr_orig = np.get_printoptions()["threshold"]
+        np.set_printoptions(threshold=8)
+
+        string = "{\n"
+        for k, v in self.items():
+            if "\n" in str(v):
+                rv = str(v).replace("\n", "\n    ")
+                string += f" '{k}':\n    {rv},\n"
+            else:
+                string += f" '{k}': {v},\n"
+        string += "}"
+
         tmp_attrs = self.attrs.copy()
-        datatype = tmp_attrs.pop("datatype")
-        # __repr__ instead of __str__ to avoid infinite loop
-        string = datatype + " = " + super().__repr__()
-        if len(tmp_attrs) > 0:
-            string += "\nattrs = " + str(tmp_attrs)
+        tmp_attrs.pop("datatype")
+        if tmp_attrs:
+            string += f" with attrs={tmp_attrs}"
+
+        np.set_printoptions(threshold=thr_orig)
+
         return string
 
     def __repr__(self) -> str:
-        return str(self)
+        npopt = np.get_printoptions()
+        np.set_printoptions(threshold=5, edgeitems=2, linewidth=100)
+        out = (
+            self.__class__.__name__
+            + "(dict="
+            + dict.__repr__(self)
+            + f", attrs={repr(self.attrs)})"
+        )
+        np.set_printoptions(**npopt)
+        return " ".join(out.replace("\n", " ").split())
diff --git a/src/pygama/lgdo/table.py b/src/pygama/lgdo/table.py
index dd0aa5bef..b98b943f8 100644
--- a/src/pygama/lgdo/table.py
+++ b/src/pygama/lgdo/table.py
@@ -5,11 +5,16 @@
 from __future__ import annotations
 
 import logging
+import re
 from typing import Any, Union
 
+import numexpr as ne
+import numpy as np
 import pandas as pd
+from pandas.io.formats import format as fmt
 
 from pygama.lgdo.array import Array
+from pygama.lgdo.arrayofequalsizedarrays import ArrayOfEqualSizedArrays
 from pygama.lgdo.scalar import Scalar
 from pygama.lgdo.struct import Struct
 from pygama.lgdo.vectorofvectors import VectorOfVectors
@@ -95,8 +100,8 @@ def resize(self, new_size: int = None, do_warn: bool = False) -> None:
             elif len(obj) != new_size:
                 if do_warn:
                     log.warning(
-                        f"warning: resizing field {field}",
-                        f"with size {len(obj)} != {new_size}",
+                        f"warning: resizing field {field}"
+                        f"with size {len(obj)} != {new_size}"
                     )
                 if isinstance(obj, Table):
                     obj.resize(new_size)
@@ -149,6 +154,10 @@ def add_column(
         """Alias for :meth:`.add_field` using table terminology 'column'."""
         self.add_field(name, obj, use_obj_size=use_obj_size, do_warn=do_warn)
 
+    def remove_column(self, name: str, delete: bool = False) -> None:
+        """Alias for :meth:`.remove_field` using table terminology 'column'."""
+        super().remove_field(name, delete)
+
     def join(
         self, other_table: Table, cols: list[str] = None, do_warn: bool = True
     ) -> None:
@@ -199,9 +208,134 @@ def get_dataframe(self, cols: list[str] = None, copy: bool = False) -> pd.DataFr
         if cols is None:
             cols = self.keys()
         for col in cols:
-            if not hasattr(self[col], "nda"):
+            if isinstance(self[col], VectorOfVectors):
+                column = self[col].to_aoesa()
+            else:
+                column = self[col]
+
+            if not hasattr(column, "nda"):
                 raise ValueError(f"column {col} does not have an nda")
             else:
-                df[col] = self[col].nda
+                df[col] = column.nda.tolist()
 
         return df
+
+    def eval(self, expr_config: dict) -> Table:
+        """Apply column operations to the table and return a new table holding
+        the resulting columns.
+
+        Currently defers all the job to :meth:`numexpr.evaluate`. This
+        might change in the future.
+
+        Parameters
+        ----------
+        expr_config
+            dictionary that configures expressions according the following
+            specification:
+
+            .. code-block:: js
+
+                {
+                    "O1": {
+                        "expression": "p1 + p2 * a**2",
+                        "parameters": {
+                            "p1": "2",
+                            "p2": "3"
+                        }
+                    },
+                    "O2": {
+                        "expression": "O1 - b"
+                    }
+                    // ...
+                }
+
+            where:
+
+            - ``expression`` is an expression string supported by
+              :meth:`numexpr.evaluate` (see also `here
+              <https://numexpr.readthedocs.io/projects/NumExpr3/en/latest/index.html>`_
+              for documentation). Note: because of internal limitations, reduction operations must appear the last in the stack.
+            - ``parameters`` is a dictionary of function parameters. Passed to
+              :meth:`numexpr.evaluate`` as `local_dict` argument.
+
+
+        Warning
+        -------
+        Blocks in `expr_config` must be ordered according to mutual dependency.
+        """
+        out_tbl = Table(size=self.size)
+        for out_var, spec in expr_config.items():
+            in_vars = {}
+            # Find all valid python variables in expression (e.g "a*b+sin(Cool)" --> ['a','b','sin','Cool'])
+            for elem in re.findall(r"\s*[A-Za-z_]\w*\s*", spec["expression"]):
+                elem = elem.strip()
+                if elem in self:  # check if the variable comes from dsp
+                    in_vars[elem] = self[elem]
+                elif (
+                    elem in out_tbl.keys()
+                ):  # if not try from previously processed data, else ignore since it is e.g sin func
+                    in_vars[elem] = out_tbl[elem]
+
+                else:
+                    continue
+                # get the nda if it is an Array instance
+                if isinstance(in_vars[elem], Array):
+                    in_vars[elem] = in_vars[elem].nda
+                # No vector of vectors support yet
+                elif isinstance(in_vars[elem], VectorOfVectors):
+                    raise TypeError("Data of type VectorOfVectors not supported (yet)")
+
+            out_data = ne.evaluate(
+                f"{spec['expression']}",
+                local_dict=dict(in_vars, **spec["parameters"])
+                if "parameters" in spec
+                else in_vars,
+            )  # Division is chosen by __future__.division in the interpreter
+
+            # smart way to find right LGDO data type:
+
+            # out_data has one row and this row has a scalar (eg scalar product of two rows)
+            if len(np.shape(out_data)) == 0:
+                out_data = Array(nda=out_data)
+
+            # out_data has scalar in each row
+            elif len(np.shape(out_data)) == 1:
+                out_data = Array(nda=out_data)
+
+            # out_data is  like
+            elif len(np.shape(out_data)) == 2:
+                out_data = ArrayOfEqualSizedArrays(nda=out_data)
+
+            # higher order data (eg matrix product of ArrayOfEqualSizedArrays) not supported yet
+            else:
+                ValueError(
+                    f"Calculation resulted in {len(np.shape(out_data))-1}-D row which is not supported yet"
+                )
+
+            out_tbl.add_column(out_var, out_data)
+
+        return out_tbl
+
+    def __str__(self):
+        opts = fmt.get_dataframe_repr_params()
+        opts["show_dimensions"] = False
+        opts["index"] = False
+
+        try:
+            string = self.get_dataframe().to_string(**opts)
+        except ValueError:
+            string = "Cannot print Table with VectorOfVectors yet!"
+
+        string += "\n"
+        for k, v in self.items():
+            tmp_attrs = v.attrs.copy()
+            tmp_attrs.pop("datatype")
+            if tmp_attrs:
+                string += f"\nwith attrs['{k}']={tmp_attrs}"
+
+        tmp_attrs = self.attrs.copy()
+        tmp_attrs.pop("datatype")
+        if tmp_attrs:
+            string += f"\nwith attrs={tmp_attrs}"
+
+        return string
diff --git a/src/pygama/lgdo/vectorofvectors.py b/src/pygama/lgdo/vectorofvectors.py
index c0971e64c..a97dca3f8 100644
--- a/src/pygama/lgdo/vectorofvectors.py
+++ b/src/pygama/lgdo/vectorofvectors.py
@@ -11,6 +11,7 @@
 from numba import njit
 
 from pygama.lgdo.array import Array
+from pygama.lgdo.arrayofequalsizedarrays import ArrayOfEqualSizedArrays
 from pygama.lgdo.lgdo_utils import get_element_type
 
 log = logging.getLogger(__name__)
@@ -62,7 +63,10 @@ def __init__(
                 shape=(shape_guess[0],), dtype="uint32", fill_val=0
             )
         else:
-            self.cumulative_length = cumulative_length
+            if isinstance(cumulative_length, Array):
+                self.cumulative_length = cumulative_length
+            else:
+                self.cumulative_length = Array(cumulative_length)
         if flattened_data is None:
             length = np.prod(shape_guess)
             if dtype is None:
@@ -71,7 +75,10 @@ def __init__(
                 self.flattened_data = Array(shape=(length,), dtype=dtype)
                 self.dtype = np.dtype(dtype)
         else:
-            self.flattened_data = flattened_data
+            if isinstance(flattened_data, Array):
+                self.flattened_data = flattened_data
+            else:
+                self.flattened_data = Array(flattened_data)
             if dtype is None:
                 self.dtype = self.flattened_data.dtype
             else:
@@ -128,6 +135,20 @@ def set_vector(self, i_vec: int, nda: np.ndarray) -> None:
         self.flattened_data.nda[start:end] = nda
         self.cumulative_length.nda[i_vec] = end
 
+    def get_vector(self, i_vec: int) -> np.ndarray:
+        """Get vector at index `i_vec`."""
+        if i_vec >= len(self.cumulative_length) or i_vec < 0:
+            raise IndexError
+
+        if i_vec == 0:
+            start = 0
+            end = self.cumulative_length.nda[0]
+        else:
+            start = self.cumulative_length.nda[i_vec - 1]
+            end = self.cumulative_length.nda[i_vec]
+
+        return self.flattened_data.nda[start:end]
+
     def __iter__(self) -> VectorOfVectors:
         self.index = 0
         return self
@@ -150,17 +171,52 @@ def __getitem__(self, index: int) -> list:
         return list(self)[index]
 
     def __str__(self) -> str:
-        """Convert to string (e.g. for printing)."""
-        nda = list(self)
-        string = str(nda)
+        string = ""
+        pos = 0
+        for vec in self:
+            if pos != 0:
+                string += " "
+
+            string += np.array2string(vec, prefix=" ")
+
+            if pos < len(self.cumulative_length):
+                string += ",\n"
+
+            pos += 1
+
+        string = f"[{string}]"
+
         tmp_attrs = self.attrs.copy()
         tmp_attrs.pop("datatype")
         if len(tmp_attrs) > 0:
-            string += "\n" + str(tmp_attrs)
+            string += f" with attrs={tmp_attrs}"
+
         return string
 
     def __repr__(self) -> str:
-        return str(self)
+        npopt = np.get_printoptions()
+        np.set_printoptions(threshold=5, edgeitems=2, linewidth=100)
+        out = (
+            "VectorOfVectors(flattened_data="
+            + repr(self.flattened_data)
+            + ", cumulative_length="
+            + repr(self.cumulative_length)
+            + ", attrs="
+            + repr(self.attrs)
+            + ")"
+        )
+        np.set_printoptions(**npopt)
+        return out
+
+    def to_aoesa(self) -> ArrayOfEqualSizedArrays:
+        """Convert to ArrayOfEqualSizedArrays, padding with NaNs"""
+        ind_lengths = np.diff(self.cumulative_length.nda, prepend=0)
+        arr_len = np.max(ind_lengths)
+        nda = np.empty((len(self.cumulative_length), arr_len))
+        nda.fill(np.nan)
+        for i in range(len(self.cumulative_length)):
+            nda[i, : ind_lengths[i]] = self[i]
+        return ArrayOfEqualSizedArrays(nda=nda)
 
 
 def build_cl(
diff --git a/src/pygama/lgdo/waveform_table.py b/src/pygama/lgdo/waveform_table.py
index 9514badb0..2283d0979 100644
--- a/src/pygama/lgdo/waveform_table.py
+++ b/src/pygama/lgdo/waveform_table.py
@@ -235,3 +235,27 @@ def dt_units(self) -> str:
     @dt_units.setter
     def dt_units(self, units: str) -> None:
         self.dt.attrs["units"] = f"{units}"
+
+    def __str__(self):
+        npopt = np.get_printoptions()
+        np.set_printoptions(threshold=100)
+
+        string = ""
+
+        for i in range(self.size):
+            if isinstance(self.values, VectorOfVectors):
+                string += f"{self.values.get_vector(i)}"
+            else:
+                string += f"{self.values.nda[i]}"
+
+            string += f", dt={self.dt.nda[i]}"
+            if self.dt_units:
+                string += f" {self.dt_units}"
+            string += f", t0={self.t0.nda[i]}"
+            if self.t0_units:
+                string += f" {self.t0_units}"
+            if i < self.size - 1:
+                string += "\n"
+
+        np.set_printoptions(**npopt)
+        return string
diff --git a/src/pygama/logging.py b/src/pygama/logging.py
new file mode 100644
index 000000000..220a90448
--- /dev/null
+++ b/src/pygama/logging.py
@@ -0,0 +1,28 @@
+"""This module implements some helpers for setting up logging."""
+import logging
+
+import colorlog
+
+
+def setup(level: int = logging.INFO, logger: logging.Logger = None) -> None:
+    """Setup a colorful logging output.
+
+    If `logger` is None, sets up only the ``pygama`` logger.
+
+    Parameters
+    ----------
+    level
+        logging level (see :mod:`logging` module).
+    logger
+        if not `None`, setup this logger.
+    """
+    handler = colorlog.StreamHandler()
+    handler.setFormatter(
+        colorlog.ColoredFormatter("%(log_color)s%(name)s [%(levelname)s] %(message)s")
+    )
+
+    if logger is None:
+        logger = colorlog.getLogger("pygama")
+
+    logger.setLevel(level)
+    logger.addHandler(handler)
diff --git a/src/pygama/math/histogram.py b/src/pygama/math/histogram.py
index e8060a8e1..f79ac3c7f 100644
--- a/src/pygama/math/histogram.py
+++ b/src/pygama/math/histogram.py
@@ -468,7 +468,7 @@ def get_gaussian_guess(hist, bins):
     guess_amp = hist[max_idx]
 
     # find 50% amp bounds on both sides for a FWHM guess
-    guess_sigma = get_fwhm(hist, bins) / 2.355  # FWHM to sigma
+    guess_sigma = get_fwhm(hist, bins)[0] / 2.355  # FWHM to sigma
     guess_area = guess_amp * guess_sigma * np.sqrt(2 * np.pi)
 
     return (guess_e, guess_sigma, guess_area)
diff --git a/src/pygama/math/peak_fitting.py b/src/pygama/math/peak_fitting.py
index bf2fb44ce..145c20535 100644
--- a/src/pygama/math/peak_fitting.py
+++ b/src/pygama/math/peak_fitting.py
@@ -103,7 +103,8 @@ def fit_binned(func, hist, bins, var=None, guess=None,
 
         if len(bins) == len(hist)+1:
             bin_centres = pgh.get_bin_centers(bins)
-
+        else:
+             bin_centres = bins
         # skip "okay" bins with content 0 +/- 0
         # if bin content is non-zero but var = 0 let the user see the warning
         zeros = (hist == 0)
@@ -169,7 +170,7 @@ def fit_unbinned(func, data, guess=None,
     m.hesse()
     return m.values, m.errors, m.covariance
 
-def goodness_of_fit(hist, bins, var, func, pars, method='var'):
+def goodness_of_fit(hist, bins, var, func, pars, method='var', scale_bins=False):
     """Compute chisq and dof of fit
 
     Parameters
@@ -207,7 +208,9 @@ def goodness_of_fit(hist, bins, var, func, pars, method='var'):
 
 
     # compute expected values
-    yy = func(pgh.get_bin_centers(bins), *pars) * pgh.get_bin_widths(bins)
+    yy = func(pgh.get_bin_centers(bins), *pars) 
+    if scale_bins ==True:
+        yy*= pgh.get_bin_widths(bins)
 
     if method == 'LR':
         log_lr = 2*np.sum(np.where(hist>0 , yy-hist + hist*np.log((hist+1.e-99) / (yy+1.e-99)), yy-hist))
@@ -811,7 +814,7 @@ def neg_radford_peak_bgfree(E, sigma, htail, tau):
         return -gauss_with_tail_pdf(np.array([E]), 0, sigma, htail, tau)[0]
 
     if htail<0 or htail>1:
-        print("htail outside allowed limits of 0 and 1")
+        #print("htail outside allowed limits of 0 and 1")
         raise ValueError
 
     res = minimize_scalar( neg_radford_peak_bgfree,
diff --git a/src/pygama/pargen/AoE_cal.py b/src/pygama/pargen/AoE_cal.py
new file mode 100644
index 000000000..fa24f7519
--- /dev/null
+++ b/src/pygama/pargen/AoE_cal.py
@@ -0,0 +1,1484 @@
+"""
+This module provides functions for correcting the a/e energy dependence, determining the cut level and calculating survival fractions.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import pathlib
+
+import matplotlib as mpl
+import matplotlib.cm as cmx
+import matplotlib.colors as mcolors
+import matplotlib.pyplot as plt
+import numpy as np
+from iminuit import Minuit, cost, util
+from matplotlib.backends.backend_pdf import PdfPages
+from matplotlib.colors import LogNorm
+
+import pygama.lgdo.lh5_store as lh5
+import pygama.math.histogram as pgh
+import pygama.math.peak_fitting as pgf
+import pygama.pargen.ecal_th as thc
+import pygama.pargen.energy_cal as pgc
+
+log = logging.getLogger(__name__)
+
+
+def load_aoe(
+    files: list, lh5_path: str, cal_dict: dict, energy_param: str, cal_energy_param: str
+) -> tuple(np.array, np.array, np.array, np.array):
+
+    """
+    Loads in the A/E parameters needed and applies calibration constants to energy
+    """
+
+    sto = lh5.LH5Store()
+
+    params = ["A_max", "tp_0_est", "tp_99", "dt_eff", energy_param, cal_energy_param]
+
+    table = sto.read_object(lh5_path, files)[0]
+    df = table.eval(cal_dict).get_dataframe()
+
+    param_dict = {}
+    for param in params:
+        # add cuts in here
+        if param in df:
+            param_dict[param] = df[param].to_numpy()
+        else:
+            param_dict.update(lh5.load_nda(files, [param], lh5_path))
+    if "Quality_cuts" in df.keys():
+        for entry in param_dict:
+            param_dict[entry] = param_dict[entry][df["Quality_cuts"].to_numpy()]
+
+    aoe = np.divide(param_dict["A_max"], param_dict[energy_param])
+    full_dt = param_dict["tp_99"] - param_dict["tp_0_est"]
+    return aoe, param_dict[cal_energy_param], param_dict["dt_eff"], full_dt
+
+
+def PDF_AoE(
+    x: np.array,
+    lambda_s: float,
+    lambda_b: float,
+    mu: float,
+    sigma: float,
+    tau: float,
+    lower_range: float = np.inf,
+    upper_range: float = np.inf,
+    components: bool = False,
+) -> tuple(float, np.array):
+    """
+    PDF for A/E consists of a gaussian signal with gaussian tail background
+    """
+    try:
+        sig = lambda_s * pgf.gauss_norm(x, mu, sigma)
+        bkg = lambda_b * pgf.gauss_tail_norm(
+            x, mu, sigma, tau, lower_range, upper_range
+        )
+    except:
+        sig = np.full_like(x, np.nan)
+        bkg = np.full_like(x, np.nan)
+
+    if components == False:
+        pdf = sig + bkg
+        return lambda_s + lambda_b, pdf
+    else:
+        return lambda_s + lambda_b, sig, bkg
+
+
+def unbinned_aoe_fit(
+    aoe: np.array, display: int = 0, verbose: bool = False
+) -> tuple(np.array, np.array):
+
+    """
+    Fitting function for A/E, first fits just a gaussian before using the full pdf to fit
+    if fails will return NaN values
+    """
+
+    hist, bins, var = pgh.get_hist(aoe, bins=500)
+    bin_centers = (bins[:-1] + bins[1:]) / 2
+
+    pars, cov = pgf.gauss_mode_max(hist, bins)
+    mu = bin_centers[np.argmax(hist)]
+    _, sigma, _ = pgh.get_gaussian_guess(hist, bins)
+    ls_guess = 2 * np.sum(hist[(bin_centers > mu) & (bin_centers < (mu + 2.5 * sigma))])
+    c1_min = mu - 2 * sigma
+    c1_max = mu + 5 * sigma
+
+    # Initial fit just using Gaussian
+    c1 = cost.UnbinnedNLL(aoe[(aoe < c1_max) & (aoe > c1_min)], pgf.gauss_pdf)
+    m1 = Minuit(c1, mu, sigma, ls_guess)
+    m1.limits = [
+        (mu * 0.8, mu * 1.2),
+        (0.8 * sigma, sigma * 1.2),
+        (0, len(aoe[(aoe < c1_max) & (aoe > c1_min)])),
+    ]
+    m1.migrad()
+    mu, sigma, ls_guess = m1.values
+    if verbose:
+        print(m1)
+
+    # Range to fit over, below this tail behaviour more exponential, few events above
+    fmin = mu - 15 * sigma
+    fmax = mu + 5 * sigma
+
+    bg_guess = len(aoe[(aoe < fmax) & (aoe > fmin)]) - ls_guess
+    x0 = [ls_guess, bg_guess, mu, sigma, 0.01, fmin, fmax, 0]
+    if verbose:
+        print(x0)
+
+    # Full fit using gaussian signal with gaussian tail background
+    c = cost.ExtendedUnbinnedNLL(aoe[(aoe < fmax) & (aoe > fmin)], PDF_AoE)
+    m = Minuit(c, *x0)
+    m.fixed[5:] = True
+    m.simplex().migrad()
+    m.hesse()
+    if verbose:
+        print(m)
+
+    valid = m.valid & m.accurate
+    if valid == False:
+        return np.full_like(x0, np.nan), np.full_like(x0, np.nan)
+
+    if display > 1:
+        plt.figure()
+        xs = np.linspace(fmin, fmax, 1000)
+        counts, bins, bars = plt.hist(
+            aoe[(aoe < fmax) & (aoe > fmin)], bins=400, histtype="step", label="Data"
+        )
+        dx = np.diff(bins)
+        plt.plot(xs, PDF_AoE(xs, *m.values)[1] * dx[0], label="Full fit")
+        n_events, sig, bkg = PDF_AoE(xs, *m.values[:-1], True)
+        plt.plot(xs, sig * dx[0], label="Signal")
+        plt.plot(xs, bkg * dx[0], label="Background")
+        plt.plot(xs, pgf.gauss_pdf(xs, *m1.values) * dx[0], label="Initial Gaussian")
+        plt.legend(loc="upper left")
+        plt.show()
+
+        plt.figure()
+        bin_centers = (bins[1:] + bins[:-1]) / 2
+        res = (PDF_AoE(bin_centers, *m.values)[1] * dx[0]) - counts
+        plt.plot(
+            bin_centers,
+            [re / count if count != 0 else re for re, count in zip(res, counts)],
+            label="Normalised Residuals",
+        )
+        plt.legend(loc="upper left")
+        plt.show()
+        return m.values, m.errors
+
+    else:
+        return m.values, m.errors
+
+
+def pol1(x: np.array, a: float, b: float) -> np.array:
+    """Basic Polynomial for fitting A/E centroid against energy"""
+    return a * x + b
+
+
+def sigma_fit(x: np.array, a: float, b: float) -> np.array:
+    """Function definition for fitting A/E sigma against energy"""
+    return np.sqrt(a + (b / x) ** 2)
+
+
+def AoEcorrection(
+    energy: np.array, aoe: np.array, eres: list, pdf_path: str = None, display: int = 0
+) -> tuple(np.array, np.array):
+
+    """
+    Calculates the corrections needed for the energy dependence of the A/E.
+    Does this by fitting the compton continuum in slices and then applies fits to the centroid and variance.
+    """
+
+    comptBands_width = 20
+    comptBands = np.array(
+        [
+            940,
+            960,
+            980,
+            1000,
+            1020,
+            1040,
+            1130,
+            1150,
+            1170,
+            1190,
+            1210,
+            1250,
+            1270,
+            1290,
+            1310,
+            1330,
+            1370,
+            1390,
+            1420,
+            1520,
+            1540,
+            1650,
+            1700,
+            1780,
+            1810,
+            1850,
+            1870,
+            1890,
+            1910,
+            1930,
+            1950,
+            1970,
+            1990,
+            2010,
+            2030,
+            2050,
+            2150,
+            2170,
+            2190,
+            2210,
+            2230,
+            2250,
+            2270,
+            2290,
+            2310,
+            2330,
+            2350,
+        ]
+    )
+
+    comptBands = comptBands[::-1]  # Flip so color gets darker when plotting
+    # peaks = np.array([1080,1094,1459,1512, 1552, 1592,1620, 1650, 1670,1830,2105])
+    compt_aoe = np.zeros(len(comptBands))
+    aoe_sigmas = np.zeros(len(comptBands))
+    compt_aoe_err = np.zeros(len(comptBands))
+    aoe_sigmas_err = np.zeros(len(comptBands))
+    ratio = np.zeros(len(comptBands))
+    ratio_err = np.zeros(len(comptBands))
+
+    copper = cm = plt.get_cmap("copper")
+    cNorm = mcolors.Normalize(vmin=0, vmax=len(comptBands))
+    scalarMap = cmx.ScalarMappable(norm=cNorm, cmap=copper)
+
+    if display > 0 or pdf_path is not None:
+        plt.figure()
+
+    # Fit each compton band
+    for i, band in enumerate(comptBands):
+        aoe_tmp = aoe[
+            (energy > band) & (energy < band + comptBands_width) & (aoe > 0)
+        ]  # [:20000]
+        pars, errs = unbinned_aoe_fit(aoe_tmp, display=display)
+        compt_aoe[i] = pars[2]
+        aoe_sigmas[i] = pars[3]
+        compt_aoe_err[i] = errs[2]
+        aoe_sigmas_err[i] = errs[3]
+        ratio[i] = pars[0] / pars[1]
+        ratio_err[i] = ratio[i] * np.sqrt(
+            (errs[0] / pars[0]) ** 2 + (errs[1] / pars[1]) ** 2
+        )
+
+        if display > 0 or pdf_path is not None:
+            if np.isnan(errs[2]) | np.isnan(errs[3]) | (errs[2] == 0) | (errs[3] == 0):
+                pass
+            else:
+                xs = np.arange(
+                    pars[2] - 4 * pars[3], pars[2] + 3 * pars[3], pars[3] / 10
+                )
+                colorVal = scalarMap.to_rgba(i)
+                plt.plot(xs, PDF_AoE(xs, *pars)[1], color=colorVal)
+
+    if display > 0 or pdf_path is not None:
+        plt.xlabel("A/E")
+        plt.ylabel("Expected Counts")
+        plt.title("Compton Band Fits")
+        cbar = plt.colorbar(
+            cmx.ScalarMappable(norm=cNorm, cmap=plt.get_cmap("copper_r")),
+            orientation="horizontal",
+            label="Compton Band Energy",
+            ticks=[0, 16, 32, len(comptBands)],
+        )  # cax=ax,
+        cbar.ax.set_xticklabels(
+            [
+                comptBands[::-1][0],
+                comptBands[::-1][16],
+                comptBands[::-1][32],
+                comptBands[::-1][-1],
+            ]
+        )
+        if pdf_path is not None:
+            pdf_path.savefig()
+        if display > 0:
+            plt.show()
+        else:
+            plt.close()
+
+    ids = (
+        np.isnan(compt_aoe_err)
+        | np.isnan(aoe_sigmas_err)
+        | (aoe_sigmas_err == 0)
+        | (compt_aoe_err == 0)
+    )
+
+    # Fit mus against energy
+    p0_mu = [-1e-06, 5e-01]
+    c_mu = cost.LeastSquares(
+        comptBands[~ids], compt_aoe[~ids], compt_aoe_err[~ids], pol1
+    )
+    c_mu.loss = "soft_l1"
+    m_mu = Minuit(c_mu, *p0_mu)
+    m_mu.simplex()
+    m_mu.migrad()
+    m_mu.hesse()
+
+    pars = m_mu.values
+    errs = m_mu.errors
+
+    # Fit sigma against energy
+    p0_sig = [np.nanpercentile(aoe_sigmas[~ids], 50) ** 2, 2]
+    c_sig = cost.LeastSquares(
+        comptBands[~ids], aoe_sigmas[~ids], aoe_sigmas_err[~ids], sigma_fit
+    )
+    c_sig.loss = "soft_l1"
+    m_sig = Minuit(c_sig, *p0_sig)
+    m_sig.simplex()
+    m_sig.migrad()
+    m_sig.hesse()
+
+    sig_pars = m_sig.values
+    sig_errs = m_sig.errors
+
+    model = pol1(comptBands, *pars)
+    sig_model = sigma_fit(comptBands, *sig_pars)
+
+    # Get DEP fit
+    sigma = np.sqrt(eres[0] + 1592 * eres[1]) / 2.355
+    n_sigma = 4
+    peak = 1592
+    emin = peak - n_sigma * sigma
+    emax = peak + n_sigma * sigma
+    dep_pars, dep_err = unbinned_aoe_fit(
+        aoe[(energy > emin) & (energy < emax) & (aoe > 0)][:10000]
+    )
+
+    if display > 0 or pdf_path is not None:
+        fig, (ax1, ax2) = plt.subplots(2, 1, constrained_layout=True, sharex=True)
+        ax1.errorbar(
+            comptBands[~ids] + 10,
+            compt_aoe[~ids],
+            yerr=compt_aoe_err[~ids],
+            xerr=10,
+            label="data",
+            linestyle=" ",
+        )
+        ax1.plot(comptBands[~ids] + 10, model[~ids], label="linear model")
+        ax1.errorbar(
+            1592,
+            dep_pars[2],
+            xerr=n_sigma * sigma,
+            yerr=dep_err[2],
+            label="DEP",
+            color="green",
+            linestyle=" ",
+        )
+
+        ax1.legend(title="A/E mu energy dependence", frameon=False)
+
+        ax1.set_ylabel("raw A/E (a.u.)", ha="right", y=1)
+        ax2.scatter(
+            comptBands[~ids] + 10,
+            100 * (compt_aoe[~ids] - model[~ids]) / compt_aoe_err[~ids],
+            lw=1,
+            c="b",
+        )
+        ax2.scatter(
+            1592, 100 * (dep_pars[2] - pol1(1592, *pars)) / dep_err[2], lw=1, c="g"
+        )
+        ax2.set_ylabel("Residuals %", ha="right", y=1)
+        ax2.set_xlabel("Energy (keV)", ha="right", x=1)
+        if pdf_path is not None:
+            pdf_path.savefig()
+        if display > 0:
+            plt.show()
+        else:
+            plt.close()
+
+    if display > 0 or pdf_path is not None:
+        fig, (ax1, ax2) = plt.subplots(2, 1, constrained_layout=True, sharex=True)
+        ax1.errorbar(
+            comptBands[~ids] + 10,
+            aoe_sigmas[~ids],
+            yerr=aoe_sigmas_err[~ids],
+            xerr=10,
+            label="data",
+            linestyle=" ",
+        )
+        ax1.plot(
+            comptBands[~ids],
+            sig_model[~ids],
+            label=f"sqrt model: sqrt({sig_pars[0]:1.4f}+({sig_pars[1]:1.1f}/E)^2)",
+        )  # {sig_pars[2]:1.1f}
+        ax1.errorbar(
+            1592,
+            dep_pars[3],
+            xerr=n_sigma * sigma,
+            yerr=dep_err[3],
+            label="DEP",
+            color="green",
+        )
+        ax1.set_ylabel("A/E stdev (a.u.)", ha="right", y=1)
+        ax1.legend(title="A/E stdev energy dependence", frameon=False)
+        ax2.scatter(
+            comptBands[~ids] + 10,
+            100 * (aoe_sigmas[~ids] - sig_model[~ids]) / aoe_sigmas_err[~ids],
+            lw=1,
+            c="b",
+        )
+        ax2.scatter(
+            1592,
+            100 * (dep_pars[3] - sigma_fit(1592, *sig_pars)) / dep_err[3],
+            lw=1,
+            c="g",
+        )
+        ax2.set_ylabel("Residuals", ha="right", y=1)
+        ax2.set_xlabel("Energy (keV)", ha="right", x=1)
+        if pdf_path is not None:
+            pdf_path.savefig()
+        if display > 0:
+            plt.show()
+        else:
+            plt.close()
+
+    return pars, sig_pars
+
+
+def plot_compt_bands_overlayed(
+    aoe: np.array, energy: np.array, eranges: list[tuple], aoe_range: list[float] = None
+) -> None:
+
+    """
+    Function to plot various compton bands to check energy dependence and corrections
+    """
+
+    for erange in eranges:
+        hist, bins, var = pgh.get_hist(
+            aoe[(energy > erange - 10) & (energy < erange + 10) & (~np.isnan(aoe))],
+            bins=500,
+        )
+        bin_cs = (bins[1:] + bins[:-1]) / 2
+        mu = bin_cs[np.argmax(hist)]
+        if aoe_range is None:
+            aoe_range = [mu * 0.97, mu * 1.02]
+        idxs = (
+            (energy > erange - 10)
+            & (energy < erange + 10)
+            & (aoe > aoe_range[0])
+            & (aoe < aoe_range[1])
+        )
+        plt.hist(aoe[idxs], bins=50, histtype="step", label=f"{erange-10}-{erange+10}")
+
+
+def plot_dt_dep(
+    aoe: np.array, energy: np.array, dt: np.array, erange: list[tuple], title: str
+) -> None:
+
+    """
+    Function to produce 2d histograms of A/E against drift time to check dependencies
+    """
+
+    hist, bins, var = pgh.get_hist(
+        aoe[(energy > erange[0]) & (energy < erange[1]) & (~np.isnan(aoe))], bins=500
+    )
+    bin_cs = (bins[1:] + bins[:-1]) / 2
+    mu = bin_cs[np.argmax(hist)]
+    aoe_range = [mu * 0.9, mu * 1.1]
+
+    idxs = (
+        (energy > erange[0])
+        & (energy < erange[1])
+        & (aoe > aoe_range[0])
+        & (aoe < aoe_range[1])
+        & (dt < 2000)
+    )
+
+    plt.hist2d(aoe[idxs], dt[idxs], bins=[200, 100], norm=LogNorm())
+    plt.ylabel("Drift Time (ns)")
+    plt.xlabel("A/E")
+    plt.title(title)
+
+
+def energy_guess(hist, bins, var, func_i, peak, eres_pars, fit_range):
+    """
+    Simple guess for peak fitting
+    """
+    if func_i == pgf.extended_radford_pdf:
+        bin_cs = (bins[1:] + bins[:-1]) / 2
+        sigma = thc.fwhm_slope(peak, *eres_pars) / 2.355
+        i_0 = np.nanargmax(hist)
+        mu = peak
+        height = hist[i_0]
+        bg0 = np.mean(hist[-10:])
+        step = np.mean(hist[:10]) - bg0
+        htail = 1.0 / 5
+        tau = 0.5 * sigma
+
+        hstep = step / (bg0 + np.mean(hist[:10]))
+        dx = np.diff(bins)[0]
+        n_bins_range = int((3 * sigma) // dx)
+        nsig_guess = np.sum(hist[i_0 - n_bins_range : i_0 + n_bins_range])
+        nbkg_guess = np.sum(hist) - nsig_guess
+        if nbkg_guess < 0:
+            nbkg_guess = 0
+        if nsig_guess < 0:
+            nsig_guess = 0
+        parguess = [
+            nsig_guess,
+            mu,
+            sigma,
+            htail,
+            tau,
+            nbkg_guess,
+            hstep,
+            fit_range[0],
+            fit_range[1],
+            0,
+        ]  #
+        return parguess
+
+    elif func_i == pgf.extended_gauss_step_pdf:
+        mu = peak
+        sigma = thc.fwhm_slope(peak, *eres_pars) / 2.355
+        i_0 = np.argmax(hist)
+        bg = np.mean(hist[-10:])
+        step = bg - np.mean(hist[:10])
+        hstep = step / (bg + np.mean(hist[:10]))
+        dx = np.diff(bins)[0]
+        n_bins_range = int((3 * sigma) // dx)
+        nsig_guess = np.sum(hist[i_0 - n_bins_range : i_0 + n_bins_range])
+        nbkg_guess = np.sum(hist) - nsig_guess
+        if nbkg_guess < 0:
+            nbkg_guess = 0
+        if nsig_guess < 0:
+            nsig_guess = 0
+        return [nsig_guess, mu, sigma, nbkg_guess, hstep, fit_range[0], fit_range[1], 0]
+
+
+def unbinned_energy_fit(
+    energy: np.array,
+    peak: float,
+    eres_pars: list = None,
+    simplex=False,
+    guess=None,
+    verbose: bool = False,
+) -> tuple(np.array, np.array):
+
+    """
+    Fitting function for energy peaks used to calculate survival fractions
+    """
+    hist, bins, var = pgh.get_hist(
+        energy, dx=0.5, range=(np.nanmin(energy), np.nanmax(energy))
+    )
+    if guess is None:
+
+        x0 = energy_guess(
+            hist,
+            bins,
+            var,
+            pgf.extended_gauss_step_pdf,
+            peak,
+            eres_pars,
+            (np.nanmin(energy), np.nanmax(energy)),
+        )
+        c = cost.ExtendedUnbinnedNLL(energy, pgf.extended_gauss_step_pdf)
+        m = Minuit(c, *x0)
+        m.fixed[-3:] = True
+        m.simplex().migrad()
+        m.hesse()
+        x0 = m.values[:3]
+        x0 += [1 / 5, 0.2 * m.values[2]]
+        x0 += m.values[3:]
+        if verbose:
+            print(m)
+    else:
+        x0 = guess
+    if len(x0) == 0:
+        return [np.nan], [np.nan]
+
+    fixed, mask = pgc.get_hpge_E_fixed(pgf.extended_radford_pdf)
+    if verbose:
+        print(x0)
+    c = cost.ExtendedUnbinnedNLL(energy, pgf.extended_radford_pdf)
+    m = Minuit(c, *x0)
+    if x0[0] <= 0:
+        sig_lim = 0.2 * len(energy)
+    else:
+        sig_lim = 1.2 * x0[0]
+    m.limits = [
+        (0, sig_lim),
+        (None, None),
+        (None, None),
+        (0, 1),
+        (0, None),
+        (0, len(energy) * 1.1),
+        (None, None),
+        (None, None),
+        (None, None),
+        (None, None),
+    ]
+    for fix in fixed:
+        m.fixed[fix] = True
+    if simplex == True:
+        m.simplex().migrad()
+    else:
+        m.migrad()
+
+    m.hesse()
+    if verbose:
+        print(m)
+
+    valid = m.valid
+
+    if valid == True and not np.isnan(m.errors[:-3]).all():
+        return m.values, m.errors
+    else:
+        x0 = energy_guess(
+            hist,
+            bins,
+            var,
+            pgf.extended_radford_pdf,
+            peak,
+            eres_pars,
+            (np.nanmin(energy), np.nanmax(energy)),
+        )
+        c = cost.ExtendedUnbinnedNLL(energy, pgf.extended_radford_pdf)
+        m = Minuit(c, *x0)
+        if x0[0] <= 0:
+            sig_lim = 0.2 * len(energy)
+        else:
+            sig_lim = 1.2 * x0[0]
+        m.limits = [
+            (0, sig_lim),
+            (None, None),
+            (None, None),
+            (0, 1),
+            (0, None),
+            (0, len(energy) * 1.1),
+            (None, None),
+            (None, None),
+            (None, None),
+            (None, None),
+        ]
+        for fix in fixed:
+            m.fixed[fix] = True
+        m.simplex().migrad()
+        m.hesse()
+        valid = m.valid
+        if verbose:
+            print(m)
+        if valid == True:
+            return m.values, m.errors
+        else:
+            return np.full_like(x0, np.nan), np.full_like(x0, np.nan)
+
+
+def get_peak_label(peak: float) -> str:
+    if peak == 2039:
+        return "CC @"
+    elif peak == 1592.5:
+        return "Tl DEP @"
+    elif peak == 1620.5:
+        return "Bi FEP @"
+    elif peak == 2103.53:
+        return "Tl SEP @"
+    elif peak == 2614.5:
+        return "Tl FEP @"
+
+
+def get_survival_fraction(
+    energy,
+    aoe,
+    cut_val,
+    peak,
+    eres_pars,
+    high_cut=None,
+    guess_pars_cut=None,
+    guess_pars_surv=None,
+):
+    if high_cut is not None:
+        idxs = (aoe > cut_val) & (aoe < high_cut)
+    else:
+        idxs = aoe > cut_val
+    cut_pars, ct_errs = unbinned_energy_fit(
+        energy[~idxs], peak, eres_pars, guess=guess_pars_cut, simplex=True
+    )
+    surv_pars, surv_errs = unbinned_energy_fit(
+        energy[idxs], peak, eres_pars, guess=guess_pars_surv, simplex=True
+    )
+
+    ct_n = cut_pars[0]
+    ct_err = ct_errs[0]
+    surv_n = surv_pars[0]
+    surv_err = surv_errs[0]
+
+    pc_n = ct_n + surv_n
+    pc_err = np.sqrt(surv_err**2 + ct_err**2)
+
+    sf = (surv_n / pc_n) * 100
+    err = sf * np.sqrt((pc_err / pc_n) ** 2 + (surv_err / surv_n) ** 2)
+    return sf, err, cut_pars, surv_pars
+
+
+def get_aoe_cut_fit(
+    energy: np.array,
+    aoe: np.array,
+    peak: float,
+    ranges: tuple(int, int),
+    dep_acc: float,
+    eres_pars: list,
+    display: int = 1,
+) -> float:
+
+    """
+    Determines A/E cut by sweeping through values and for each one fitting the DEP to determine how many events survive.
+    Then interpolates to get cut value at desired DEP survival fraction (typically 90%)
+    """
+
+    min_range, max_range = ranges
+
+    peak_energy = energy[(energy > peak - min_range) & (energy < peak + max_range)][
+        :20000
+    ]
+    peak_aoe = aoe[(energy > peak - min_range) & (energy < peak + max_range)][:20000]
+
+    cut_vals = np.arange(-8, 0, 0.2)
+    pars, errors = unbinned_energy_fit(peak_energy, peak, eres_pars, simplex=True)
+    if np.isnan(pars[0]):
+        print("first fit failed")
+        pars, errors = unbinned_energy_fit(peak_energy, peak, eres_pars, simplex=False)
+    pc_n = pars[0]
+    pc_err = errors[0]
+    sfs = []
+    sf_errs = []
+    for cut_val in cut_vals:
+        sf, err, cut_pars, surv_pars = get_survival_fraction(
+            peak_energy,
+            peak_aoe,
+            cut_val,
+            peak,
+            eres_pars,
+            guess_pars_cut=None,
+            guess_pars_surv=None,
+        )
+        sfs.append(sf)
+        sf_errs.append(err)
+
+    # return cut_vals, sfs, sf_errs
+    ids = (
+        (sf_errs < (1.5 * np.nanpercentile(sf_errs, 70)))
+        & (~np.isnan(sf_errs))
+        & (np.array(sfs) < 95)
+    )
+    fit = np.polynomial.polynomial.polyfit(
+        cut_vals[ids], np.array(sfs)[ids], w=1 / np.array(sf_errs)[ids], deg=6
+    )
+
+    xs = np.arange(np.nanmin(cut_vals[ids]), np.nanmax(cut_vals[ids]), 0.01)
+    p = np.polynomial.polynomial.polyval(xs, fit)
+    cut_val = xs[np.argmin(np.abs(p - (100 * dep_acc)))]
+
+    if display > 0:
+        plt.figure()
+        plt.errorbar(
+            cut_vals[ids],
+            np.array(sfs)[ids],
+            yerr=np.array(sf_errs)[ids],
+            linestyle=" ",
+        )
+
+        plt.plot(xs, p)
+        plt.show()
+
+    return cut_val
+
+
+def get_sf(
+    energy: np.array,
+    aoe: np.array,
+    peak: float,
+    fit_width: tuple(int, int),
+    aoe_cut_val: float,
+    eres_pars: list,
+    display: int = 0,
+) -> tuple(np.array, np.array, np.array, float, float):
+
+    """
+    Calculates survival fraction for gamma lines using fitting method as in cut determination
+    """
+
+    # fwhm = np.sqrt(eres[0]+peak*eres[1])
+    min_range = peak - fit_width[0]
+    max_range = peak + fit_width[1]
+    if peak == "1592.5":
+        peak_energy = energy[(energy > min_range) & (energy < max_range)][:20000]
+        peak_aoe = aoe[(energy > min_range) & (energy < max_range)][:20000]
+    else:
+        peak_energy = energy[(energy > min_range) & (energy < max_range)][:50000]
+        peak_aoe = aoe[(energy > min_range) & (energy < max_range)][:50000]
+    pars, errors = unbinned_energy_fit(peak_energy, peak, eres_pars, simplex=False)
+    pc_n = pars[0]
+    pc_err = errors[0]
+    sfs = []
+    sf_errs = []
+
+    cut_vals = np.arange(-5, 5, 0.2)
+    # cut_vals = np.append(cut_vals, aoe_cut_val)
+    final_cut_vals = []
+    for cut_val in cut_vals:
+        try:
+
+            sf, err, cut_pars, surv_pars = get_survival_fraction(
+                peak_energy, peak_aoe, cut_val, peak, eres_pars
+            )
+            if np.isnan(cut_pars).all() == False and np.isnan(surv_pars).all() == False:
+                guess_pars_cut = cut_pars
+                guess_pars_surv = surv_pars
+        except:
+            sf = np.nan
+            err = np.nan
+        sfs.append(sf)
+        sf_errs.append(err)
+        final_cut_vals.append(cut_val)
+    ids = (
+        (sf_errs < (5 * np.nanpercentile(sf_errs, 50)))
+        & (~np.isnan(sf_errs))
+        & (np.array(sfs) < 100)
+    )
+    sf, sf_err, cut_pars, surv_pars = get_survival_fraction(
+        peak_energy, peak_aoe, aoe_cut_val, peak, eres_pars
+    )
+
+    if display > 0:
+        plt.figure()
+        plt.errorbar(cut_vals, sfs, sf_errs)
+        plt.show()
+
+    return (
+        np.array(final_cut_vals)[ids],
+        np.array(sfs)[ids],
+        np.array(sf_errs)[ids],
+        sf,
+        sf_err,
+    )
+
+
+def compton_sf(
+    energy: np.array,
+    aoe: np.array,
+    cut: float,
+    peak: float,
+    eres: list[float, float],
+    display: int = 1,
+) -> tuple(float, np.array, list):
+
+    """
+    Determines survival fraction for compton continuum by basic counting
+    """
+
+    fwhm = np.sqrt(eres[0] + peak * eres[1])
+
+    emin = peak - 2 * fwhm
+    emax = peak + 2 * fwhm
+    sfs = []
+    aoe = aoe[(energy > emin) & (energy < emax)]
+    cut_vals = np.arange(-5, 5, 0.1)
+    for cut_val in cut_vals:
+        sfs.append(100 * len(aoe[(aoe > cut_val)]) / len(aoe))
+    sf = 100 * len(aoe[(aoe > cut)]) / len(aoe)
+    return sf, cut_vals, sfs
+
+
+def get_sf_no_sweep(
+    energy: np.array,
+    aoe: np.array,
+    peak: float,
+    fit_width: tuple(int, int),
+    eres_pars: list,
+    aoe_low_cut_val: float,
+    aoe_high_cut_val: float = None,
+    display: int = 1,
+) -> tuple(float, float):
+
+    """
+    Calculates survival fraction for gamma line without sweeping through values
+    """
+
+    min_range = peak - fit_width[0]
+    max_range = peak + fit_width[1]
+    if peak == "1592.5":
+        peak_energy = energy[(energy > min_range) & (energy < max_range)][:20000]
+        peak_aoe = aoe[(energy > min_range) & (energy < max_range)][:20000]
+    else:
+        peak_energy = energy[(energy > min_range) & (energy < max_range)][:50000]
+        peak_aoe = aoe[(energy > min_range) & (energy < max_range)][:50000]
+
+    sf, sf_err, cut_pars, surv_pars = get_survival_fraction(
+        peak_energy,
+        peak_aoe,
+        aoe_low_cut_val,
+        peak,
+        eres_pars,
+        high_cut=aoe_high_cut_val,
+    )
+    return sf, sf_err
+
+
+def compton_sf_no_sweep(
+    energy: np.array,
+    aoe: np.array,
+    peak: float,
+    eres: list[float, float],
+    aoe_low_cut_val: float,
+    aoe_high_cut_val: float = None,
+    display: int = 1,
+) -> float:
+
+    """
+    Calculates survival fraction for compton contiuum without sweeping through values
+    """
+
+    fwhm = np.sqrt(eres[0] + peak * eres[1])
+
+    emin = peak - 2 * fwhm
+    emax = peak + 2 * fwhm
+    sfs = []
+    aoe = aoe[(energy > emin) & (energy < emax)]
+    cut_vals = np.arange(-5, 5, 0.1)
+    if aoe_high_cut_val is None:
+        sf = 100 * len(aoe[(aoe > aoe_low_cut_val)]) / len(aoe)
+    else:
+        sf = (
+            100
+            * len(aoe[(aoe > aoe_low_cut_val) & (aoe < aoe_high_cut_val)])
+            / len(aoe)
+        )
+    return sf
+
+
+def get_classifier(
+    aoe: np.array,
+    energy: np.array,
+    mu_pars: list[float, float],
+    sigma_pars: list[float, float],
+) -> np.array:
+
+    """
+    Applies correction to A/E energy dependence
+    """
+
+    classifier = aoe / (mu_pars[0] * energy + mu_pars[1])
+    classifier = (classifier - 1) / sigma_fit(energy, *sigma_pars)
+    return classifier
+
+
+def get_dt_guess(hist: np.array, bins: np.array, var: np.array) -> list:
+    """
+    Guess for fitting dt spectrum
+    """
+
+    mu, sigma, amp = pgh.get_gaussian_guess(hist, bins)
+    i_0 = np.argmax(hist)
+    bg = np.mean(hist[-10:])
+    step = bg - np.mean(hist[:10])
+    hstep = step / (bg + np.mean(hist[:10]))
+    dx = np.diff(bins)[0]
+    n_bins_range = int((3 * sigma) // dx)
+    nsig_guess = np.sum(hist[i_0 - n_bins_range : i_0 + n_bins_range])
+    nbkg_guess = np.sum(hist) - nsig_guess
+    return [
+        nsig_guess * np.diff(bins)[0],
+        mu,
+        sigma,
+        nbkg_guess * np.diff(bins)[0],
+        hstep,
+        np.inf,
+        np.inf,
+        0,
+    ]
+
+
+def apply_dtcorr(aoe: np.array, dt: np.array, alpha: float) -> np.array:
+    """Aligns dt regions"""
+    return aoe * (1 + alpha * dt)
+
+
+def drift_time_correction(
+    aoe: np.array,
+    energy: np.array,
+    dt: np.array,
+    display: int = 0,
+    pdf_path: str = None,
+) -> tuple(np.array, float):
+    """
+    Calculates the correction needed to align the two drift time regions for ICPC detectors
+    """
+    hist, bins, var = pgh.get_hist(aoe[(energy > 1582) & (energy < 1602)], bins=500)
+    bin_cs = (bins[1:] + bins[:-1]) / 2
+    mu = bin_cs[np.argmax(hist)]
+    aoe_range = [mu * 0.9, mu * 1.1]
+
+    idxs = (
+        (energy > 1582) & (energy < 1602) & (aoe > aoe_range[0]) & (aoe < aoe_range[1])
+    )
+
+    mask = (
+        (idxs)
+        & (dt < np.nanpercentile(dt[idxs], 55))
+        & (dt > np.nanpercentile(dt[idxs], 1))
+    )
+
+    hist, bins, var = pgh.get_hist(
+        dt[mask], dx=10, range=(np.nanmin(dt[mask]), np.nanmax(dt[mask]))
+    )
+
+    gpars = get_dt_guess(hist, bins, var)
+    dt_pars, dt_errs, dt_cov = pgf.fit_binned(
+        pgf.gauss_step_pdf,
+        hist,
+        bins,
+        guess=gpars,
+        fixed=[-3, -2, -1],
+        cost_func="Least Squares",
+    )
+
+    aoe_mask = (
+        (idxs) & (dt > dt_pars[1] - 2 * dt_pars[2]) & (dt < dt_pars[1] + 2 * dt_pars[2])
+    )
+    aoe_tmp = aoe[aoe_mask]
+    aoe_pars, aoe_errs = unbinned_aoe_fit(aoe_tmp, display=display)
+
+    mask2 = (
+        (idxs)
+        & (dt > np.nanpercentile(dt[idxs], 50))
+        & (dt < np.nanpercentile(dt[idxs], 99))
+    )
+    hist2, bins2, var2 = pgh.get_hist(
+        dt[mask2], dx=10, range=(np.nanmin(dt[mask2]), np.nanmax(dt[mask2]))
+    )
+    gpars2 = get_dt_guess(hist2, bins2, var2)
+
+    dt_pars2, dt_errs2, dt_cov2 = pgf.fit_binned(
+        pgf.gauss_step_pdf,
+        hist2,
+        bins2,
+        guess=gpars2,
+        fixed=[-3, -2, -1],
+        cost_func="Least Squares",
+    )
+
+    aoe_mask2 = (
+        (idxs)
+        & (dt > dt_pars2[1] - 2 * dt_pars2[2])
+        & (dt < dt_pars2[1] + 2 * dt_pars2[2])
+    )
+    aoe_tmp2 = aoe[aoe_mask2]
+    aoe_pars2, aoe_errs2 = unbinned_aoe_fit(aoe_tmp2, display=display)
+
+    alpha = (aoe_pars[2] - aoe_pars2[2]) / (
+        dt_pars2[1] * aoe_pars2[2] - dt_pars[1] * aoe_pars[2]
+    )
+    aoe_corrected = apply_dtcorr(aoe, dt, alpha)
+
+    if display > 0 or pdf_path is not None:
+        plt.figure()
+        plt.subplot(3, 2, 1)
+        plt.step(pgh.get_bin_centers(bins), hist, label="Data")
+        plt.plot(
+            pgh.get_bin_centers(bins),
+            pgf.gauss_step_pdf(pgh.get_bin_centers(bins), *gpars),
+            label="Guess",
+        )
+        plt.plot(
+            pgh.get_bin_centers(bins),
+            pgf.gauss_step_pdf(pgh.get_bin_centers(bins), *dt_pars),
+            label="Fit",
+        )
+        plt.xlabel("Drift Time (ns)")
+        plt.ylabel("Counts")
+        plt.legend(loc="upper left")
+
+        plt.subplot(3, 2, 2)
+        plt.step(pgh.get_bin_centers(bins2), hist2, label="Data")
+        plt.plot(
+            pgh.get_bin_centers(bins2),
+            pgf.gauss_step_pdf(pgh.get_bin_centers(bins2), *gpars2),
+            label="Guess",
+        )
+        plt.plot(
+            pgh.get_bin_centers(bins2),
+            pgf.gauss_step_pdf(pgh.get_bin_centers(bins2), *dt_pars2),
+            label="Fit",
+        )
+        plt.xlabel("Drift Time (ns)")
+        plt.ylabel("Counts")
+        plt.legend(loc="upper left")
+
+        plt.subplot(3, 2, 3)
+        xs = np.linspace(aoe_pars[-3], aoe_pars[-2], 1000)
+        counts, aoe_bins, bars = plt.hist(
+            aoe[(aoe < aoe_pars[-2]) & (aoe > aoe_pars[-3]) & aoe_mask],
+            bins=400,
+            histtype="step",
+            label="Data",
+        )
+        dx = np.diff(aoe_bins)
+        plt.plot(xs, PDF_AoE(xs, *aoe_pars)[1] * dx[0], label="Full fit")
+        # plt.yscale('log')
+        n_events, sig, bkg = PDF_AoE(xs, *aoe_pars[:-1], True)
+        plt.plot(xs, sig * dx[0], label="Peak fit")
+        plt.plot(xs, bkg * dx[0], label="Bkg fit")
+        plt.legend(loc="upper left")
+        plt.xlabel("A/E")
+        plt.ylabel("Counts")
+
+        plt.subplot(3, 2, 4)
+        xs = np.linspace(aoe_pars2[-3], aoe_pars2[-2], 1000)
+        counts, aoe_bins2, bars = plt.hist(
+            aoe[(aoe < aoe_pars2[-2]) & (aoe > aoe_pars2[-3]) & aoe_mask2],
+            bins=400,
+            histtype="step",
+            label="Data",
+        )
+        dx = np.diff(aoe_bins2)
+        plt.plot(xs, PDF_AoE(xs, *aoe_pars2)[1] * dx[0], label="Full fit")
+        # plt.yscale('log')
+        n_events, sig, bkg = PDF_AoE(xs, *aoe_pars2[:-1], True)
+        plt.plot(xs, sig * dx[0], label="Peak fit")
+        plt.plot(xs, bkg * dx[0], label="Bkg fit")
+        plt.legend(loc="upper left")
+        plt.xlabel("A/E")
+        plt.ylabel("Counts")
+
+        plt.subplot(3, 2, 5)
+        counts, bins, bars = plt.hist(
+            aoe[idxs], bins=200, histtype="step", label="Uncorrected"
+        )
+        plt.hist(aoe_corrected[idxs], bins=bins, histtype="step", label="Corrected")
+        plt.xlabel("A/E")
+        plt.ylabel("Counts")
+        plt.legend(loc="upper left")
+        plt.tight_layout()
+        plt.xlim([aoe_pars[-3], aoe_pars[-2] * 1.01])
+        if pdf_path is not None:
+            pdf_path.savefig()
+        if display > 0:
+            plt.show()
+        else:
+            plt.close()
+    return aoe_corrected, alpha
+
+
+def cal_aoe(
+    files: list,
+    lh5_path,
+    cal_dict: dict,
+    energy_param: str,
+    cal_energy_param: str,
+    eres_pars: list,
+    dt_corr: bool = False,
+    plot_savepath: str = None,
+) -> tuple(dict, dict):
+    """
+    Main function for running the a/e correction and cut determination.
+    """
+
+    aoe_uncorr, energy, dt, full_dt = load_aoe(
+        files, lh5_path, cal_dict, energy_param, cal_energy_param
+    )
+
+    if dt_corr == True:
+        aoe, alpha = drift_time_correction(aoe_uncorr, energy, dt)
+    else:
+        aoe = aoe_uncorr
+
+    log.info("Starting A/E correction")
+    mu_pars, sigma_pars = AoEcorrection(energy, aoe, eres_pars)
+    log.info("Finished A/E correction")
+
+    classifier = get_classifier(aoe, energy, mu_pars, sigma_pars)
+
+    cut = get_aoe_cut_fit(energy, classifier, 1592, (40, 20), 0.9, eres_pars, display=0)
+
+    cal_dict.update(
+        {
+            "AoE_Corrected": {
+                "expression": f"(((A_max/{energy_param})/(a*{cal_energy_param} +b))-1)",
+                "parameters": {"a": mu_pars[0], "b": mu_pars[1]},
+            }
+        }
+    )
+
+    cal_dict.update(
+        {
+            "AoE_Classifier": {
+                "expression": f"AoE_Corrected/(sqrt(c+(d/{cal_energy_param})**2)/(a*{cal_energy_param} +b))",
+                "parameters": {
+                    "a": mu_pars[0],
+                    "b": mu_pars[1],
+                    "c": sigma_pars[0],
+                    "d": sigma_pars[1],
+                },
+            }
+        }
+    )
+
+    cal_dict.update(
+        {
+            "AoE_Low_Cut": {
+                "expression": "AoE_Classifier>a",
+                "parameters": {"a": cut},
+            }
+        }
+    )
+
+    cal_dict.update(
+        {
+            "AoE_Double_Sided_Cut": {
+                "expression": "(b>AoE_Classifier)&(AoE_Classifier>a)",
+                "parameters": {"a": cut, "b": 4},
+            }
+        }
+    )
+
+    try:
+        log.info("  Compute low side survival fractions: ")
+
+        peaks_of_interest = [1592.5, 1620.5, 2039, 2103.53, 2614.50]
+        sf = np.zeros(len(peaks_of_interest))
+        sferr = np.zeros(len(peaks_of_interest))
+        fit_widths = [(40, 25), (25, 40), (0, 0), (25, 40), (50, 50)]
+        full_sfs = []
+        full_sf_errs = []
+        full_cut_vals = []
+
+        for i, peak in enumerate(peaks_of_interest):
+            if peak == 2039:
+                sf[i], cut_vals, sfs = compton_sf(
+                    energy, classifier, cut, peak, eres_pars
+                )
+                sferr[i] = 0
+
+                full_cut_vals.append(cut_vals)
+                full_sfs.append(sfs)
+                full_sf_errs.append(None)
+            else:
+                cut_vals, sfs, sf_errs, sf[i], sferr[i] = get_sf(
+                    energy, classifier, peak, fit_widths[i], cut, eres_pars
+                )
+                full_cut_vals.append(cut_vals)
+                full_sfs.append(sfs)
+                full_sf_errs.append(sf_errs)
+
+            log.info(f"{peak}keV: {sf[i]:2.1f} +/- {sferr[i]:2.1f} %")
+
+        sf_2side = np.zeros(len(peaks_of_interest))
+        sferr_2side = np.zeros(len(peaks_of_interest))
+        log.info("Calculating 2 sided cut sfs")
+        for i, peak in enumerate(peaks_of_interest):
+            if peak == 2039:
+                sf_2side[i] = compton_sf_no_sweep(
+                    energy, classifier, peak, eres_pars, cut, aoe_high_cut_val=4
+                )
+                sferr_2side[i] = 0
+            else:
+                sf_2side[i], sferr_2side[i] = get_sf_no_sweep(
+                    energy,
+                    classifier,
+                    peak,
+                    fit_widths[i],
+                    eres_pars,
+                    cut,
+                    aoe_high_cut_val=4,
+                )
+
+            log.info(f"{peak}keV: {sf[i]:2.1f} +/- {sferr[i]:2.1f} %")
+        log.info("Done")
+
+        if plot_savepath is not None:
+            mpl.use("pdf")
+            pathlib.Path(os.path.dirname(plot_savepath)).mkdir(
+                parents=True, exist_ok=True
+            )
+            with PdfPages(plot_savepath) as pdf:
+
+                plt.rcParams["figure.figsize"] = (12, 8)
+                plt.rcParams["font.size"] = 16
+
+                plt.figure()
+                plt.subplot(3, 2, 1)
+                plot_dt_dep(aoe, energy, dt, [1582, 1602], f"Tl DEP")
+                plt.subplot(3, 2, 2)
+                plot_dt_dep(aoe, energy, dt, [1510, 1630], f"Bi FEP")
+                plt.subplot(3, 2, 3)
+                plot_dt_dep(aoe, energy, dt, [2030, 2050], "Qbb")
+                plt.subplot(3, 2, 4)
+                plot_dt_dep(aoe, energy, dt, [2080, 2120], f"Tl SEP")
+                plt.subplot(3, 2, 5)
+                plot_dt_dep(aoe, energy, dt, [2584, 2638], f"Tl FEP")
+                plt.tight_layout()
+                pdf.savefig()
+                plt.close()
+
+                plt.figure()
+                plot_compt_bands_overlayed(
+                    aoe, energy, [950, 1250, 1460, 1660, 1860, 2060]
+                )
+                plt.ylabel("Counts")
+                plt.xlabel("Raw A/E")
+                plt.title(f"Compton Bands before Correction")
+                plt.legend(loc="upper left")
+                pdf.savefig()
+                plt.close()
+
+                if dt_corr == True:
+                    _ = drift_time_correction(aoe_uncorr, energy, dt, pdf_path=pdf)
+
+                mu_pars, sigma_pars = AoEcorrection(energy, aoe, eres_pars, pdf)
+
+                plt.figure()
+                plot_compt_bands_overlayed(
+                    classifier, energy, [950, 1250, 1460, 1660, 1860, 2060], [-5, 5]
+                )
+                plt.ylabel("Counts")
+                plt.xlabel("Corrected A/E")
+                plt.title(f"Compton Bands after Correction")
+                plt.legend(loc="upper left")
+                pdf.savefig()
+                plt.close()
+
+                plt.figure()
+                plt.vlines(cut, 0, 100, label=f"Cut Value: {cut:1.2f}", color="black")
+
+                for i, peak in enumerate(peaks_of_interest):
+                    if peak == 2039:
+                        plt.plot(
+                            full_cut_vals[i],
+                            full_sfs[i],
+                            label=f"{get_peak_label(peak)} {peak} keV: {sf[i]:2.1f} +/- {sferr[i]:2.1f} %",
+                        )
+                    else:
+                        plt.errorbar(
+                            full_cut_vals[i],
+                            full_sfs[i],
+                            yerr=full_sf_errs[i],
+                            label=f"{get_peak_label(peak)} {peak} keV: {sf[i]:2.1f} +/- {sferr[i]:2.1f} %",
+                        )
+
+                handles, labels = plt.gca().get_legend_handles_labels()
+                order = [1, 2, 3, 0, 4, 5]
+                plt.legend(
+                    [handles[idx] for idx in order],
+                    [labels[idx] for idx in order],
+                    loc="upper right",
+                )
+                plt.xlabel("Cut Value")
+                plt.ylabel("Survival Fraction %")
+                pdf.savefig()
+                plt.close()
+
+                fig, ax = plt.subplots()
+                bins = np.linspace(1000, 3000, 2000)
+                ax.hist(energy, bins=bins, histtype="step", label="Before PSD")
+                ax.hist(
+                    energy[classifier > cut],
+                    bins=bins,
+                    histtype="step",
+                    label="Low side PSD cut",
+                )
+                ax.hist(
+                    energy[(classifier > cut) & (classifier < 4)],
+                    bins=bins,
+                    histtype="step",
+                    label="Double sided PSD cut",
+                )
+                ax.hist(
+                    energy[(classifier < cut) | (classifier > 4)],
+                    bins=bins,
+                    histtype="step",
+                    label="Rejected by PSD cut",
+                )
+
+                axins = ax.inset_axes([0.25, 0.1, 0.3, 0.3])
+                bins = np.linspace(1580, 1640, 200)
+                axins.hist(energy, bins=bins, histtype="step")
+                axins.hist(energy[classifier > cut], bins=bins, histtype="step")
+                axins.hist(
+                    energy[(classifier > cut) & (classifier < 4)],
+                    bins=bins,
+                    histtype="step",
+                )
+                axins.hist(
+                    energy[(classifier < cut) | (classifier > 4)],
+                    bins=bins,
+                    histtype="step",
+                )
+                axins.set_xlabel("Energy (keV)")
+                axins.set_ylabel("Counts")
+                ax.set_xlim([1000, 3000])
+                ax.set_yscale("log")
+                plt.xlabel("Energy (keV)")
+                plt.ylabel("Counts")
+                plt.legend()
+                pdf.savefig()
+                plt.close()
+
+                plt.figure()
+                bins = np.linspace(1000, 3000, 1000)
+                counts_pass, bins_pass, _ = pgh.get_hist(
+                    energy[(classifier > cut) & (classifier < 4)], bins=bins
+                )
+                counts, bins, _ = pgh.get_hist(energy, bins=bins)
+                survival_fracs = counts_pass / (counts)
+
+                plt.step(pgh.get_bin_centers(bins_pass), survival_fracs)
+                plt.xlabel("Energy (keV)")
+                plt.ylabel("Survival Fraction")
+                plt.ylim([0, 1])
+                pdf.savefig()
+                plt.close()
+
+        def convert_sfs_to_dict(peaks_of_interest, sfs, sf_errs):
+            out_dict = {}
+            for i, peak in enumerate(peaks_of_interest):
+                out_dict[str(peak)] = {
+                    "sf": f"{sfs[i]:2f}",
+                    "sf_err": f"{sf_errs[i]:2f}",
+                }
+            return out_dict
+
+        out_dict = {
+            "A/E_Energy_param": "cuspEmax",
+            "Cal_energy_param": "cuspEmax_ctc",
+            "dt_param": "dt_eff",
+            "rt_correction": False,
+            "Mean_pars": list(mu_pars),
+            "Sigma_pars": list(sigma_pars),
+            "Low_cut": cut,
+            "High_cut": 4,
+            "Low_side_sfs": convert_sfs_to_dict(peaks_of_interest, sf, sferr),
+            "2_side_sfs": convert_sfs_to_dict(peaks_of_interest, sf_2side, sferr_2side),
+        }
+
+    except:
+        log.error("survival fraction determination failed")
+        pathlib.Path(plot_savepath).touch()
+        out_dict = {
+            "A/E_Energy_param": "cuspEmax",
+            "Cal_energy_param": "cuspEmax_ctc",
+            "dt_param": "dt_eff",
+            "rt_correction": False,
+            "Mean_pars": list(mu_pars),
+            "Sigma_pars": list(sigma_pars),
+            "Low_cut": cut,
+            "High_cut": 4,
+        }
+
+    log.info(f"Results are {out_dict}")
+    return cal_dict, out_dict
diff --git a/src/pygama/pargen/cuts.py b/src/pygama/pargen/cuts.py
new file mode 100644
index 000000000..d6de1e318
--- /dev/null
+++ b/src/pygama/pargen/cuts.py
@@ -0,0 +1,297 @@
+"""
+This module provides routines for calculating and applying quality cuts
+"""
+
+from __future__ import annotations
+
+import glob
+import json
+import logging
+import os
+
+import numpy as np
+import pandas as pd
+from scipy import stats
+
+import pygama.lgdo.lh5_store as lh5
+import pygama.math.histogram as pgh
+import pygama.math.peak_fitting as pgf
+import pygama.pargen.energy_cal as pgc
+
+log = logging.getLogger(__name__)
+
+
+def generate_cuts(data: dict[str, np.ndarray], parameters: list[str]) -> dict:
+    """
+    Finds double sided cut boundaries for a file for the parameters specified
+
+    Parameters
+    ----------
+    data : lh5 table or dictionary of arrays
+                data to calculate cuts on
+    parameters : dict
+                 dictionary with the parameter to be cut and the number of sigmas to cut at
+    """
+
+    output_dict = {}
+    for par in parameters.keys():
+        num_sigmas = parameters[par]
+        par_array = data[par]
+        if not isinstance(par_array, np.ndarray):
+            par_array = par_array.nda
+        counts, start_bins, var = pgh.get_hist(par_array, 10**5)
+        max_idx = np.argmax(counts)
+        mu = start_bins[max_idx]
+        try:
+            pars, cov = pgf.gauss_mode_width_max(
+                counts,
+                start_bins,
+                mode_guess=mu,
+                n_bins=10,
+                cost_func="Least Squares",
+                inflate_errors=False,
+                gof_method="var",
+            )
+
+            guess_sig = pars[2]
+
+            lower_bound = mu - 10 * guess_sig
+
+            upper_bound = mu + 10 * guess_sig
+
+        except:
+            bin_range = 1000
+
+            if max_idx < bin_range:
+                lower_bound_idx = 0
+            else:
+                lower_bound_idx = max_idx - bin_range
+            lower_bound = start_bins[lower_bound_idx]
+
+            if max_idx > len(start_bins) - bin_range:
+                upper_bound_idx = -1
+            else:
+                upper_bound_idx = max_idx + bin_range
+
+            upper_bound = start_bins[upper_bound_idx]
+
+        try:
+            counts, bins, var = pgh.get_hist(
+                par_array, bins=1000, range=(lower_bound, upper_bound)
+            )
+
+            bin_centres = pgh.get_bin_centers(bins)
+
+            fwhm = pgh.get_fwhm(counts, bins)[0]
+            mean = float(bin_centres[np.argmax(counts)])
+            std = fwhm / 2.355
+        except IndexError:
+            bin_range = 5000
+
+            if max_idx < bin_range:
+                lower_bound_idx = 0
+            else:
+                lower_bound_idx = max_idx - bin_range
+            lower_bound = start_bins[lower_bound_idx]
+
+            if max_idx > len(start_bins) - bin_range:
+                upper_bound_idx = -1
+            else:
+                upper_bound_idx = max_idx + bin_range
+            upper_bound = start_bins[upper_bound_idx]
+            counts, bins, var = pgh.get_hist(
+                par_array, bins=1000, range=(lower_bound, upper_bound)
+            )
+
+            bin_centres = pgh.get_bin_centers(bins)
+
+            fwhm = pgh.get_fwhm(counts, bins)[0]
+            mean = float(bin_centres[np.argmax(counts)])
+            std = fwhm / 2.355
+
+        if isinstance(num_sigmas, (int, float)):
+            num_sigmas_left = num_sigmas
+            num_sigmas_right = num_sigmas
+        elif isinstance(num_sigmas, dict):
+            num_sigmas_left = num_sigmas["left"]
+            num_sigmas_right = num_sigmas["right"]
+        upper = float((num_sigmas_right * std) + mean)
+        lower = float((-num_sigmas_left * std) + mean)
+        output_dict[par] = {
+            "Mean Value": mean,
+            "Sigmas Cut": num_sigmas,
+            "Upper Boundary": upper,
+            "Lower Boundary": lower,
+        }
+    return output_dict
+
+
+def get_cut_indexes(
+    all_data: dict[str, np.ndarray], cut_dict: dict, energy_param: str = "trapTmax"
+) -> list[int]:
+
+    """
+    Returns a mask of the data, for a single file, that passes cuts based on dictionary of cuts
+    in form of cut boundaries above
+    Parameters
+    ----------
+    File : dict or lh5_table
+           dictionary of parameters + array such as load_nda or lh5 table of params
+    Cut_dict : string
+                Dictionary file with cuts
+    """
+
+    indexes = None
+    keys = cut_dict.keys()
+    for cut in keys:
+        data = all_data[cut]
+        if not isinstance(data, np.ndarray):
+            data = data.nda
+
+        upper = cut_dict[cut]["Upper Boundary"]
+        lower = cut_dict[cut]["Lower Boundary"]
+        idxs = (data < upper) & (data > lower)
+        percent = 100 * len(np.where(idxs)[0]) / len(idxs)
+        log.info(f"{percent:.2f}% passed {cut} cut")
+
+        # Combine masks
+        if indexes is not None:
+            indexes = indexes & idxs
+
+        else:
+            indexes = idxs
+        log.debug(f"{cut} loaded")
+    percent = 100 * len(np.where(indexes)[0]) / len(indexes)
+    log.info(f"{percent:.2f}% passed all cuts")
+    return indexes
+
+
+def cut_dict_to_hit_dict(cut_dict):
+    out_dict = {}
+    for param in cut_dict:
+
+        out_dict[f"{param}_cut"] = {
+            "expression": f"(a<{param})&({param}<b)",
+            "parameters": {
+                "a": cut_dict[param]["Lower Boundary"],
+                "b": cut_dict[param]["Upper Boundary"],
+            },
+        }
+    quality_cut_exp = ""
+    for par in list(cut_dict)[:-1]:
+        quality_cut_exp += f"({par}_cut)&"
+    quality_cut_exp += f"({list(cut_dict)[-1]}_cut)"
+    out_dict["Quality_cuts"] = {"expression": quality_cut_exp, "parameters": {}}
+    return out_dict
+
+
+def find_pulser_properties(df, energy="daqenergy"):
+
+    hist, bins, var = pgh.get_hist(df[energy], dx=1, range=(100, np.nanmax(df[energy])))
+    if np.any(var == 0):
+        var[np.where(var == 0)] = 1
+    imaxes = pgc.get_i_local_maxima(hist / np.sqrt(var), 5)
+    peak_energies = pgh.get_bin_centers(bins)[imaxes]
+    pt_pars, pt_covs = pgc.hpge_fit_E_peak_tops(
+        hist, bins, var, peak_energies, n_to_fit=15
+    )
+    peak_e_err = pt_pars[:, 1] * 4
+
+    out_pulsers = []
+    for i, e in enumerate(peak_energies):
+        if peak_e_err[i] > 200:
+            continue
+        else:
+            try:
+                e_cut = (df[energy] > e - peak_e_err[i]) & (
+                    df[energy] < e + peak_e_err[i]
+                )
+                df_peak = df[e_cut]
+
+                time_since_last = (
+                    df_peak.timestamp.values[1:] - df_peak.timestamp.values[:-1]
+                )
+
+                tsl = time_since_last[
+                    (time_since_last >= 0)
+                    & (time_since_last < np.percentile(time_since_last, 99.9))
+                ]
+
+                bins = np.arange(0.1, 5, 0.0001)
+                bcs = pgh.get_bin_centers(bins)
+                hist, bins, var = pgh.get_hist(tsl, bins=bins)
+
+                maxs = pgc.get_i_local_maxima(hist, 40)
+                if len(maxs) < 2:
+                    continue
+                else:
+
+                    max_locs = np.array([0.0])
+                    max_locs = np.append(max_locs, bcs[np.array(maxs)])
+                    if (
+                        len(np.where(np.abs(np.diff(np.diff(max_locs))) <= 0.001)[0])
+                        > 1
+                        or (np.abs(np.diff(np.diff(max_locs))) <= 0.001).all()
+                    ):
+                        pulser_e = e
+                        period = stats.mode(tsl).mode[0]
+                        out_pulsers.append((pulser_e, peak_e_err[i], period, energy))
+
+                    else:
+                        continue
+            except:
+                continue
+    return out_pulsers
+
+
+def tag_pulsers(df, chan_info, window=0.01):
+    df["isPulser"] = 0
+
+    if isinstance(chan_info, tuple):
+        chan_info = [chan_info]
+    final_mask = None
+    for chan_i in chan_info:
+        pulser_energy, peak_e_err, period, energy_name = chan_i
+
+        e_cut = (df[energy_name] < pulser_energy + peak_e_err) & (
+            df[energy_name] > pulser_energy - peak_e_err
+        )
+        df_pulser = df[e_cut]
+
+        time_since_last = np.zeros(len(df_pulser))
+        time_since_last[1:] = (
+            df_pulser.timestamp.values[1:] - df_pulser.timestamp.values[:-1]
+        )
+
+        mode_idxs = (time_since_last > period - window) & (
+            time_since_last < period + window
+        )
+
+        pulser_events = np.count_nonzero(mode_idxs)
+        # print(f"pulser events: {pulser_events}")
+        if pulser_events < 3:
+            return df
+        df_pulser = df_pulser[mode_idxs]
+
+        ts = df_pulser.timestamp.values
+        diff_zero = np.zeros(len(ts))
+        diff_zero[1:] = np.around(np.divide(np.subtract(ts[1:], ts[:-1]), period))
+        diff_cum = np.cumsum(diff_zero)
+        z = np.polyfit(diff_cum, ts, 1)
+        p = np.poly1d(z)
+
+        period = z[0]
+        phase = z[1]
+        pulser_mod = np.abs(df_pulser.timestamp - phase) % period
+        mod = np.abs(df.timestamp - phase) % period
+
+        period_cut = (mod < 0.1) | ((period - mod) < 0.1)  # 0.1)
+
+        if final_mask is None:
+            final_mask = e_cut & period_cut
+        else:
+            final_mask = final_mask | (e_cut & period_cut)
+
+    df.loc[final_mask, "isPulser"] = 1
+
+    return df
diff --git a/src/pygama/pargen/data_cleaning.py b/src/pygama/pargen/data_cleaning.py
index c7997a1e3..a6119245f 100644
--- a/src/pygama/pargen/data_cleaning.py
+++ b/src/pygama/pargen/data_cleaning.py
@@ -17,41 +17,40 @@
 
 
 def gaussian_cut(data, cut_sigma=3, plotAxis=None):
-    '''
+    """
     fits data to a gaussian, returns mean +/- cut_sigma values for a cut
-    '''
+    """
 
     nbins = 100
 
     median = np.median(data)
-    width = (np.percentile(data, 80) - np.percentile(data, 20))
+    width = np.percentile(data, 80) - np.percentile(data, 20)
 
-    good_data = data[(data > (median - 4 * width)) &
-                     (data < (median + 4 * width))]
+    good_data = data[(data > (median - 4 * width)) & (data < (median + 4 * width))]
 
-    hist, bins = np.histogram(good_data, bins=101)  #np.linspace(1,5,101)
+    hist, bins = np.histogram(good_data, bins=101)  # np.linspace(1,5,101)
     bin_centers = bins[:-1] + (bins[1] - bins[0]) / 2
 
-    #fit gaussians to that
+    # fit gaussians to that
     # result = fit_unbinned(gauss, hist, [median, width/2] )
     # print("unbinned: {}".format(result))
 
     result = fit_binned(
-        nb_gauss, hist, bin_centers,
-        [median, width / 2,
-         np.amax(hist) * (width / 2) * np.sqrt(2 * np.pi)])
+        gauss,
+        hist,
+        bin_centers,
+        [median, width / 2, np.amax(hist) * (width / 2) * np.sqrt(2 * np.pi)],
+    )
     # print("binned: {}".format(result))
     cut_lo = result[0] - cut_sigma * result[1]
     cut_hi = result[0] + cut_sigma * result[1]
 
     if plotAxis is not None:
-        plotAxis.plot(
-            bin_centers, hist, ls="steps-mid", color="k", label="data")
-        fit = nb_gauss(bin_centers, *result)
+        plotAxis.plot(bin_centers, hist, ls="steps-mid", color="k", label="data")
+        fit = gauss(bin_centers, *result)
         plotAxis.plot(bin_centers, fit, label="gaussian fit")
         plotAxis.axvline(result[0], color="g", label="fit mean")
-        plotAxis.axvline(
-            cut_lo, color="r", label=f"+/- {cut_sigma} sigma")
+        plotAxis.axvline(cut_lo, color="r", label=f"+/- {cut_sigma} sigma")
         plotAxis.axvline(cut_hi, color="r")
         plotAxis.legend()
         # plt.xlabel(params[i])
@@ -60,32 +59,31 @@ def gaussian_cut(data, cut_sigma=3, plotAxis=None):
 
 
 def xtalball_cut(data, cut_sigma=3, plotFigure=None):
-    '''
+    """
     fits data to a crystalball, returns mean +/- cut_sigma values for a cut
-    '''
+    """
 
     nbins = 100
 
     median = np.median(data)
-    width = (np.percentile(data, 80) - np.percentile(data, 20))
+    width = np.percentile(data, 80) - np.percentile(data, 20)
 
-    good_data = data[(data > (median - 4 * width)) &
-                     (data < (median + 4 * width))]
+    good_data = data[(data > (median - 4 * width)) & (data < (median + 4 * width))]
 
-    hist, bins = np.histogram(good_data, bins=101)  #np.linspace(1,5,101)
+    hist, bins = np.histogram(good_data, bins=101)  # np.linspace(1,5,101)
     bin_centers = bins[:-1] + (bins[1] - bins[0]) / 2
 
-    #fit gaussians to that
+    # fit gaussians to that
     # result = fit_unbinned(gauss, hist, [median, width/2] )
     # print("unbinned: {}".format(result))
     p0 = get_gaussian_guess(hist, bin_centers)
-    bounds = [(p0[0] * .5, p0[1] * .5, p0[2] * .2, 0, 1),
-              (p0[0] * 1.5, p0[1] * 1.5, p0[2] * 5, np.inf, np.inf)]
+    bounds = [
+        (p0[0] * 0.5, p0[1] * 0.5, p0[2] * 0.2, 0, 1),
+        (p0[0] * 1.5, p0[1] * 1.5, p0[2] * 5, np.inf, np.inf),
+    ]
     result = fit_binned(
-        nb_xtalball_pdf,
-        hist,
-        bin_centers, [p0[0], p0[1], p0[2], 10, 1],
-        bounds=bounds)
+        xtalball, hist, bin_centers, [p0[0], p0[1], p0[2], 10, 1], bounds=bounds
+    )
     # print("binned: {}".format(result))
     cut_lo = result[0] - cut_sigma * result[1]
     cut_hi = result[0] + cut_sigma * result[1]
@@ -109,23 +107,22 @@ def find_pulser_properties(df, energy="trap_max"):
 
     # print (df[energy])
     # exit()
-    #find pulser by looking for which peak has a constant time between events
-    #df should already be grouped by channel
+    # find pulser by looking for which peak has a constant time between events
+    # df should already be grouped by channel
 
-    peak_energies, peak_e_err = get_most_prominent_peaks(
-        df[energy], max_num_peaks=10)
+    peak_energies, peak_e_err = get_most_prominent_peaks(df[energy], max_num_peaks=10)
     peak_e_err *= 3
 
     for e in peak_energies:
         e_cut = (df[energy] > e - peak_e_err) & (df[energy] < e + peak_e_err)
         df_peak = df[e_cut]
         # df_after_0 = df_peak.iloc[1:]
-        time_since_last = df_peak.timestamp.values[
-            1:] - df_peak.timestamp.values[:-1]
+        time_since_last = df_peak.timestamp.values[1:] - df_peak.timestamp.values[:-1]
 
         tsl = time_since_last[
-            (time_since_last >= 0) &
-            (time_since_last < np.percentile(time_since_last, 99.9))]
+            (time_since_last >= 0)
+            & (time_since_last < np.percentile(time_since_last, 99.9))
+        ]
         last_ten = np.percentile(tsl, 97) - np.percentile(tsl, 90)
         first_ten = np.percentile(tsl, 10) - np.percentile(tsl, 3)
         # print("{:e}, {:e}".format(last_ten,first_ten))
@@ -159,23 +156,27 @@ def tag_pulsers(df, chan_info, window=250):
     # pulser_energy, peak_e_err, period, energy_name = chan_info
 
     e_cut = (df[energy_name] < pulser_energy + peak_e_err) & (
-        df[energy_name] > pulser_energy - peak_e_err)
+        df[energy_name] > pulser_energy - peak_e_err
+    )
     df_pulser = df[e_cut]
 
     time_since_last = np.zeros(len(df_pulser))
-    time_since_last[
-        1:] = df_pulser.timestamp.values[1:] - df_pulser.timestamp.values[:-1]
+    time_since_last[1:] = (
+        df_pulser.timestamp.values[1:] - df_pulser.timestamp.values[:-1]
+    )
 
     # plt.figure()
     # plt.hist(time_since_last, bins=1000)
     # plt.show()
 
-    mode_idxs = (time_since_last > period - window) & (time_since_last <
-                                                       period + window)
+    mode_idxs = (time_since_last > period - window) & (
+        time_since_last < period + window
+    )
 
     pulser_events = np.count_nonzero(mode_idxs)
     # print("pulser events: {}".format(pulser_events))
-    if pulser_events < 3: return df
+    if pulser_events < 3:
+        return df
 
     df_pulser = df_pulser[mode_idxs]
 
diff --git a/src/pygama/pargen/dsp_optimize.py b/src/pygama/pargen/dsp_optimize.py
index 0843cccd5..1dd52e4fc 100644
--- a/src/pygama/pargen/dsp_optimize.py
+++ b/src/pygama/pargen/dsp_optimize.py
@@ -8,7 +8,9 @@
 from pygama.dsp import build_processing_chain
 
 
-def run_one_dsp(tb_data, dsp_config, db_dict=None, fom_function=None, verbosity=0, fom_kwargs=None):
+def run_one_dsp(
+    tb_data, dsp_config, db_dict=None, fom_function=None, verbosity=0, fom_kwargs=None
+):
     """
     run one iteration of DSP on tb_data
 
@@ -43,31 +45,36 @@ def run_one_dsp(tb_data, dsp_config, db_dict=None, fom_function=None, verbosity=
         If fom_function is None, returns the output lh5 table for the DSP iteration
     """
 
-    pc, lh5_col_names, tb_out = build_processing_chain(tb_data, dsp_config, db_dict=db_dict, verbosity=verbosity)
+    pc, lh5_col_names, tb_out = build_processing_chain(
+        tb_data, dsp_config, db_dict=db_dict
+    )
     pc.execute()
     if fom_function is not None:
         if fom_kwargs is not None:
             return fom_function(tb_out, verbosity, fom_kwargs)
         else:
             return fom_function(tb_out, verbosity)
-    else: return tb_out
+    else:
+        return tb_out
+
 
+ParGridDimension = namedtuple("ParGridDimension", "name parameter value_strs")
 
-ParGridDimension = namedtuple('ParGridDimension', 'name parameter value_strs')
 
-class ParGrid():
-    """ Parameter Grid class
+class ParGrid:
+    """Parameter Grid class
     Each ParGrid entry corresponds to a dsp parameter to be varied.
     The ntuples must follow the pattern:
     ( name parameter value_strs) : ( str, str, list of str)
     where name and parameter are the same as 'db.name.parameter' in the processing chain,
     value_strs is the array of strings to set the argument to.
     """
+
     def __init__(self):
         self.dims = []
 
     def add_dimension(self, name, parameter, value_strs):
-        self.dims.append( ParGridDimension(name, parameter, value_strs) )
+        self.dims.append(ParGridDimension(name, parameter, value_strs))
 
     def get_n_dimensions(self):
         return len(self.dims)
@@ -85,7 +92,7 @@ def get_n_grid_points(self):
         return np.prod(self.get_shape())
 
     def get_par_meshgrid(self, copy=False, sparse=False):
-        """ return a meshgrid of parameter values
+        """return a meshgrid of parameter values
         Always uses Matrix indexing (natural for par grid) so that
         mg[i1][i2][...] corresponds to index order in self.dims
         Note copy is False by default as opposed to numpy default of True
@@ -93,13 +100,13 @@ def get_par_meshgrid(self, copy=False, sparse=False):
         axes = []
         for i in range(self.get_n_dimensions()):
             axes.append(self.dims[i].values_strs)
-        return np.meshgrid(*axes, copy, sparse, indexing='ij')
+        return np.meshgrid(*axes, copy, sparse, indexing="ij")
 
     def get_zero_indices(self):
         return np.zeros(self.get_n_dimensions(), dtype=np.uint32)
 
     def iterate_indices(self, indices):
-        """ iterate given indices [i1, i2, ...] by one.
+        """iterate given indices [i1, i2, ...] by one.
         For easier iteration. The convention here is arbitrary, but its the
         order the arrays would be traversed in a series of nested for loops in
         the order appearin in dims (first dimension is first for loop, etc):
@@ -107,11 +114,12 @@ def iterate_indices(self, indices):
         """
         for iD in reversed(range(self.get_n_dimensions())):
             indices[iD] += 1
-            if indices[iD] < self.get_n_points_of_dim(iD): return True
+            if indices[iD] < self.get_n_points_of_dim(iD):
+                return True
             indices[iD] = 0
         return False
 
-    #def check_indices(self, indices):
+    # def check_indices(self, indices):
     #    for iD in reversed(range(self.get_n_dimensions())):
     #        if indices[iD] < self.get_n_points_of_dim(iD): return True
     #        indices[iD] = 0
@@ -133,15 +141,17 @@ def set_dsp_pars(self, db_dict, indices):
         if db_dict is None:
             db_dict = {}
         for i_dim, i_par in enumerate(indices):
-            name, parameter, value_str= self.get_data(i_dim, i_par)
+            name, parameter, value_str = self.get_data(i_dim, i_par)
             if name not in db_dict.keys():
-                db_dict[name] = {parameter:value_str}
+                db_dict[name] = {parameter: value_str}
             else:
                 db_dict[name][parameter] = value_str
         return db_dict
 
 
-def run_grid(tb_data, dsp_config, grid, fom_function, db_dict=None, verbosity=1, **fom_kwargs):
+def run_grid(
+    tb_data, dsp_config, grid, fom_function, db_dict=None, verbosity=1, **fom_kwargs
+):
     """Extract a table of optimization values for a grid of DSP parameters
     The grid argument defines a list of parameters and values over which to run
     the DSP defined in dsp_config on tb_data. At each point, a scalar
@@ -179,79 +189,106 @@ def run_grid(tb_data, dsp_config, grid, fom_function, db_dict=None, verbosity=1,
         of the grid argument
     """
 
-    grid_values = np.ndarray(shape=grid.get_shape(), dtype='O')
+    grid_values = np.ndarray(shape=grid.get_shape(), dtype="O")
     iii = grid.get_zero_indices()
-    if verbosity > 0: print("starting grid calculations...")
+    if verbosity > 0:
+        print("starting grid calculations...")
     while True:
         db_dict = grid.set_dsp_pars(db_dict, iii)
-        if verbosity > 1: pprint(dsp_config)
-        if verbosity > 0: grid.print_data(iii)
-        grid_values[tuple(iii)] = run_one_dsp(tb_data,
-                                              dsp_config,
-                                              db_dict=db_dict,
-                                              fom_function=fom_function,
-                                              verbosity=verbosity, fom_kwargs=fom_kwargs)
-        if verbosity > 0: print("value:", grid_values[tuple(iii)])
-        if not grid.iterate_indices(iii): break
+        if verbosity > 1:
+            pprint(dsp_config)
+        if verbosity > 0:
+            grid.print_data(iii)
+        grid_values[tuple(iii)] = run_one_dsp(
+            tb_data,
+            dsp_config,
+            db_dict=db_dict,
+            fom_function=fom_function,
+            verbosity=verbosity,
+            fom_kwargs=fom_kwargs,
+        )
+        if verbosity > 0:
+            print("value:", grid_values[tuple(iii)])
+        if not grid.iterate_indices(iii):
+            break
     return grid_values
 
-def run_grid_point(tb_data, dsp_config, grids, fom_function, iii, db_dict=None, verbosity=1, fom_kwargs=None):
+
+def run_grid_point(
+    tb_data,
+    dsp_config,
+    grids,
+    fom_function,
+    iii,
+    db_dict=None,
+    verbosity=1,
+    fom_kwargs=None,
+):
     """
     Runs a single grid point for the index specified
     """
     if not isinstance(grids, list):
         grids = [grids]
-    for index, grid in zip(iii,grids):
+    for index, grid in zip(iii, grids):
         db_dict = grid.set_dsp_pars(db_dict, index)
 
-    if verbosity > 1: pprint(dsp_config)
+    if verbosity > 1:
+        pprint(dsp_config)
     if verbosity > 0:
-        [grid.print_data(iii[i]) for i,grid in enumerate(grids)]
-        print(' ')
-    tb_out = run_one_dsp(tb_data,
-                        dsp_config,
-                        db_dict=db_dict,
-                        verbosity=verbosity)
-    res = np.ndarray(shape=len(grids), dtype='O')
+        [grid.print_data(iii[i]) for i, grid in enumerate(grids)]
+        print(" ")
+    tb_out = run_one_dsp(tb_data, dsp_config, db_dict=db_dict, verbosity=verbosity)
+    res = np.ndarray(shape=len(grids), dtype="O")
     if fom_function:
         for i in range(len(grids)):
             if fom_kwargs[i] is not None:
-                if len(fom_function)>1:
+                if len(fom_function) > 1:
                     res[i] = fom_function[i](tb_out, verbosity, fom_kwargs[i])
                 else:
                     res[i] = fom_function[0](tb_out, verbosity, fom_kwargs[i])
             else:
-                if len(fom_function)>1:
+                if len(fom_function) > 1:
                     res[i] = fom_function[i](tb_out, verbosity)
                 else:
                     res[i] = fom_function[0](tb_out, verbosity)
-        print("value:",res)
-        out = {'indexes': [tuple(ii) for ii in iii], 'results': res}
+        print("value:", res)
+        out = {"indexes": [tuple(ii) for ii in iii], "results": res}
 
     else:
-        out = {'indexes': [tuple(ii) for ii in iii], 'results': tb_out}
+        out = {"indexes": [tuple(ii) for ii in iii], "results": tb_out}
     return out
 
+
 def get_grid_points(grid):
     """
     Generates a list of the indices of all possible grid points
     """
     out = []
     iii = [gri.get_zero_indices() for gri in grid]
-    complete =np.full(len(grid), False)
+    complete = np.full(len(grid), False)
     while True:
         out.append([tuple(ii) for ii in iii])
 
-        for i, gri in enumerate(zip(iii,grid)):
+        for i, gri in enumerate(zip(iii, grid)):
             if not gri[1].iterate_indices(gri[0]):
                 print(f"{i} grid end")
-                iii[i]=gri[1].get_zero_indices()
-                complete[i]=True
-        if all(complete): break
+                iii[i] = gri[1].get_zero_indices()
+                complete[i] = True
+        if all(complete):
+            break
     return out
 
-def run_grid_multiprocess_parallel(tb_data, dsp_config, grid, fom_function, db_dict=None, verbosity=1,
-                          processes=5, fom_kwargs=None):
+
+def run_grid_multiprocess_parallel(
+    tb_data,
+    dsp_config,
+    grid,
+    fom_function,
+    db_dict=None,
+    verbosity=1,
+    processes=5,
+    fom_kwargs=None,
+):
 
     """
     run one iteration of DSP on tb_data with multiprocessing, can handle
@@ -305,26 +342,39 @@ def run_grid_multiprocess_parallel(tb_data, dsp_config, grid, fom_function, db_d
     shapes = [gri.get_shape() for gri in grid]
     if fom_function is not None:
         for i in range(len(grid)):
-            grid_values.append(np.ndarray(shape = shapes[i], dtype='O'))
+            grid_values.append(np.ndarray(shape=shapes[i], dtype="O"))
     else:
         grid_lengths = np.array([gri.get_n_grid_points() for gri in grid])
-        grid_values.append(np.ndarray(shape = shapes[np.argmax(grid_lengths)], dtype='O'))
+        grid_values.append(np.ndarray(shape=shapes[np.argmax(grid_lengths)], dtype="O"))
     grid_list = get_grid_points(grid)
     pool = mp.Pool(processes=processes)
-    results = [pool.apply_async(run_grid_point, args=(tb_data,dsp_config, grid, fom_function, np.asarray(gl),
-                    db_dict, verbosity, fom_kwargs)) for gl in grid_list]
+    results = [
+        pool.apply_async(
+            run_grid_point,
+            args=(
+                tb_data,
+                dsp_config,
+                grid,
+                fom_function,
+                np.asarray(gl),
+                db_dict,
+                verbosity,
+                fom_kwargs,
+            ),
+        )
+        for gl in grid_list
+    ]
 
     for result in results:
         res = result.get()
-        indexes = res['indexes']
+        indexes = res["indexes"]
         if fom_function is not None:
             for i in range(len(grid)):
                 index = indexes[i]
                 if grid_values[i][index] is None:
-                    grid_values[i][index] = res['results'][i]
+                    grid_values[i][index] = res["results"][i]
         else:
-            grid_values[0][indexes[0]] = {f'{indexes[0]}':res['results']}
-
+            grid_values[0][indexes[0]] = {f"{indexes[0]}": res["results"]}
 
     pool.close()
     pool.join()
diff --git a/src/pygama/pargen/ecal_th.py b/src/pygama/pargen/ecal_th.py
index 1feb4c011..86e7a9fb4 100644
--- a/src/pygama/pargen/ecal_th.py
+++ b/src/pygama/pargen/ecal_th.py
@@ -1,4 +1,11 @@
+"""
+This module provides a routine for running the energy calibration on Th data
+"""
+
+from __future__ import annotations
+
 import json
+import logging
 import math
 import os
 import pathlib
@@ -10,256 +17,515 @@
 from matplotlib.backends.backend_pdf import PdfPages
 from scipy.optimize import curve_fit
 
-import pygama.math.distribution_selector as pgds
-import pygama.math.distributions as pgd
+import pygama.lgdo.lh5_store as lh5
 import pygama.math.histogram as pgh
+import pygama.math.peak_fitting as pgf
+import pygama.pargen.cuts as cts
 import pygama.pargen.energy_cal as cal
 
+log = logging.getLogger(__name__)
+
 
-def fwhm_slope(x, m0, m1):
+def fwhm_slope(x: np.array, m0: float, m1: float, m2: float = None) -> np.array:
     """
     Fit the energy resolution curve
     """
-    return np.sqrt(m0 + m1*x)
-
+    if m2 is None:
+        return np.sqrt(m0 + m1 * x)
+    else:
+        return np.sqrt(m0 + m1 * x + m2 * x**2)
+
+
+def load_data(
+    files: list[str],
+    lh5_path: str,
+    energy_params: list[str],
+    hit_dict: dict = {},
+    cut_parameters: list[str] = ["bl_mean", "bl_std", "pz_std"],
+) -> dict[str, np.ndarray]:
+
+    df = lh5.load_dfs(files, ["timestamp", "trapTmax"], lh5_path)
+    pulser_props = cts.find_pulser_properties(df, energy="trapTmax")
+    if len(pulser_props) > 0:
+        out_df = cts.tag_pulsers(df, pulser_props, window=0.001)
+        ids = ~(out_df.isPulser == 1)
+        log.debug(f"pulser found: {pulser_props}")
+    else:
+        ids = np.ones(len(df), dtype=bool)
+        log.debug(f"no pulser found")
+
+    if len(hit_dict.keys()) == 0:
+        try:
+            energy_dict = lh5.load_nda(files, energy_params, lh5_path)
+        except RuntimeError:
+            energy_params = [
+                energy_param.split("_")[0] for energy_param in energy_params
+            ]
+            energy_dict = lh5.load_nda(files, energy_params, lh5_path)
+    else:
+        sto = lh5.LH5Store()
+
+        table = sto.read_object(lh5_path, files)[0]
+        df = table.eval(hit_dict).get_dataframe()
+
+        energy_dict = {}
+        for param in energy_params:
+            if param in df:
+                energy_dict[param] = df[param][ids].to_numpy()
+            else:
+                dat = lh5.load_nda(files, [param], lh5_path)[param]
+                energy_dict.update({param: dat[ids]})
+        if cut_parameters is not None:
+            for param in cut_parameters:
+                if param in df:
+                    energy_dict[param] = df[param][ids].to_numpy()
+                else:
+                    dat = lh5.load_nda(files, [param], lh5_path)[param]
+                    energy_dict.update({param: dat[ids]})
+    return energy_dict
+
+
+def energy_cal_th(
+    files: list[str],
+    energy_params: list[str],
+    hit_dict: dict = {},
+    save_path: str = None,
+    plot_path: str = None,
+    cut_parameters: dict[str, int] = {"bl_mean": 4, "bl_std": 4, "pz_std": 4},
+    lh5_path: str = "dsp",
+    guess_keV: float = None,
+    threshold: int = 0,
+    p_val: float = 0.05,
+    n_events: int = 15000,
+    deg: int = 1,
+) -> tuple(dict, dict):
 
-def energy_cal_th(files, energy_params,  save_path, lh5_path='raw',n_events=15000):
     """
     This is an example script for calibrating Th data.
     """
 
-    if isinstance(energy_params, str): energy_params = [energy_params]
-
-    mpl.use('pdf')
-    plt.rcParams['figure.figsize'] = (12, 20)
-    plt.rcParams['font.size'] = 12
+    if isinstance(energy_params, str):
+        energy_params = [energy_params]
 
     ####################
     # Start the analysis
     ####################
-    print('Load and apply quality cuts...',end=' ')
-    uncal_pass = lh5.load_nda(files,energy_params,lh5_path)
-    print("Done")
-
-    Nevents = len(uncal_pass[energy_params[0]])
-    print(f'{Nevents} events pass')
-
-    glines    = [583.191, 727.330, 860.564,1592.53,1620.50,2103.53,2614.50] # gamma lines used for calibration
-    range_keV = [(20,20),(30,30), (40,40),(40,25),(25,40),(40,40),(60,60)] # side bands width
-    funcs = [pgd.nb_extended_hpge_peak_pdf,pgd.nb_extended_hpge_peak_pdf,pgd.nb_extended_hpge_peak_pdf,pgd.nb_extended_hpge_peak_pdf,
-         pgd.nb_extended_hpge_peak_pdf,pgd.nb_extended_hpge_peak_pdf,pgd.nb_extended_hpge_peak_pdf]
-    gof_funcs = [pgd.nb_gauss_step_pdf,pgd.nb_gauss_step_pdf,pgd.nb_hpge_peak_pdf,pgd.nb_hpge_peak_pdf,
-            pgd.nb_hpge_peak_pdf,pgd.nb_hpge_peak_pdf,pgd.nb_hpge_peak_pdf]
+
+    log.debug(f"{len(files)} files found")
+    log.debug("Loading and applying charge trapping corr...")
+    uncal_pass = load_data(
+        files,
+        lh5_path,
+        energy_params,
+        hit_dict,
+        cut_parameters=list(cut_parameters) if cut_parameters is not None else None,
+    )
+
+    total_events = len(uncal_pass[energy_params[0]])
+    log.debug("Done")
+    if cut_parameters is not None:
+        cut_dict = cts.generate_cuts(uncal_pass, cut_parameters)
+        hit_dict.update(cts.cut_dict_to_hit_dict(cut_dict))
+        mask = cts.get_cut_indexes(uncal_pass, cut_dict)
+        uncal_fail = {}
+        for param in uncal_pass:
+            uncal_fail[param] = uncal_pass[param][~mask]
+            uncal_pass[param] = uncal_pass[param][mask]
+        events_pqc = len(uncal_pass[energy_params[0]])
+        log.debug(f"{events_pqc} events pass")
+
+    glines = [
+        583.191,
+        727.330,
+        860.564,
+        1592.53,
+        1620.50,
+        2103.53,
+        2614.50,
+    ]  # gamma lines used for calibration
+    range_keV = [
+        (20, 20),
+        (30, 30),
+        (30, 30),
+        (40, 25),
+        (25, 40),
+        (40, 40),
+        (60, 60),
+    ]  # side bands width
+    funcs = [
+        pgf.extended_radford_pdf,
+        pgf.extended_radford_pdf,
+        pgf.extended_radford_pdf,
+        pgf.extended_radford_pdf,
+        pgf.extended_radford_pdf,
+        pgf.extended_radford_pdf,
+        pgf.extended_radford_pdf,
+    ]
+    gof_funcs = [
+        pgf.radford_pdf,
+        pgf.radford_pdf,
+        pgf.radford_pdf,
+        pgf.radford_pdf,
+        pgf.radford_pdf,
+        pgf.radford_pdf,
+        pgf.radford_pdf,
+    ]
     output_dict = {}
     for energy_param in energy_params:
-        datatype, detector, measurement, run, timestamp = os.path.basename(files[0]).split('-')
-        plot_save_path = os.path.join(save_path, 'plots', detector, f'{energy_param}.pdf')
-        pathlib.Path(os.path.dirname(plot_save_path)).mkdir(parents=True, exist_ok=True)
-
-        with PdfPages(plot_save_path) as pdf:
-
-            kev_ranges = range_keV.copy()
-            guess_keV  = (2620/np.nanpercentile(uncal_pass[energy_param],99))
-            print(f'Find peaks and compute calibration curve for {energy_param}', end = ' ')
-            pars, cov, results = cal.hpge_E_calibration(uncal_pass[energy_param],
-                                                        glines,
-                                                        guess_keV,
-                                                        deg=1,
-                                                        range_keV = range_keV,
-                                                        funcs = funcs,
-                                                        gof_funcs = gof_funcs,
-                                                        n_events=n_events,
-                                                        simplex=True,
-                                                        verbose=False
-                                                        )
-            pk_pars      = results['pk_pars']
-            found_peaks = results['got_peaks_locs']
-            fitted_peaks = results['fitted_keV']
-
-            for i, peak in enumerate(glines):
-                if peak not in fitted_peaks:
-                    kev_ranges[i] = (kev_ranges[i][0]-5,  kev_ranges[i][1]-5)
-            for i, peak in enumerate(glines):
-                if peak not in fitted_peaks:
-                    kev_ranges[i] = (kev_ranges[i][0]-5,  kev_ranges[i][1]-5)
-            for i, peak in enumerate(fitted_peaks):
-                try:
-                    if results['pk_fwhms'][:,1][i]/results['pk_fwhms'][:,0][i] >0.05:
-                        index = np.where(glines == peak)[0][0]
-                        kev_ranges[i] = (kev_ranges[index][0]-5,  kev_ranges[index][1]-5)
-                except:
-                    pass
-
-            pars, cov, results = cal.hpge_E_calibration(uncal_pass[energy_param],
-                                                        glines,
-                                                        guess_keV,
-                                                        deg=1,
-                                                        range_keV = kev_ranges,
-                                                        funcs = funcs,
-                                                        gof_funcs = gof_funcs,
-                                                        n_events=n_events,
-                                                        simplex=True,
-                                                        verbose=False
-                                                        )
-            print("done")
-            print(" ")
-            if pars is None:
-                print("Calibration failed")
+
+        kev_ranges = range_keV.copy()
+        if guess_keV is None:
+            guess_keV = 2620 / np.nanpercentile(
+                uncal_pass[energy_param][uncal_pass[energy_param] > threshold], 99
+            )
+        log.debug(f"Find peaks and compute calibration curve for {energy_param}")
+
+        pars, cov, results = cal.hpge_E_calibration(
+            uncal_pass[energy_param],
+            glines,
+            guess_keV,
+            deg=deg,
+            range_keV=range_keV,
+            funcs=funcs,
+            gof_funcs=gof_funcs,
+            n_events=n_events,
+            allowed_p_val=p_val,
+            simplex=True,
+            verbose=False,
+        )
+        pk_pars = results["pk_pars"]
+        found_peaks = results["got_peaks_locs"]
+        fitted_peaks = results["fitted_keV"]
+
+        for i, peak in enumerate(glines):
+            if peak not in fitted_peaks:
+                kev_ranges[i] = (kev_ranges[i][0] - 5, kev_ranges[i][1] - 5)
+        for i, peak in enumerate(glines):
+            if peak not in fitted_peaks:
+                kev_ranges[i] = (kev_ranges[i][0] - 5, kev_ranges[i][1] - 5)
+        for i, peak in enumerate(fitted_peaks):
+            try:
+                if results["pk_fwhms"][:, 1][i] / results["pk_fwhms"][:, 0][i] > 0.05:
+                    index = np.where(glines == peak)[0][0]
+                    kev_ranges[i] = (kev_ranges[index][0] - 5, kev_ranges[index][1] - 5)
+            except:
+                pass
+
+        pars, cov, results = cal.hpge_E_calibration(
+            uncal_pass[energy_param],
+            glines,
+            guess_keV,
+            deg=deg,
+            range_keV=kev_ranges,
+            funcs=funcs,
+            gof_funcs=gof_funcs,
+            n_events=n_events,
+            allowed_p_val=p_val,
+            simplex=True,
+            verbose=False,
+        )
+        log.debug("done")
+        if pars is None:
+            log.info("Calibration failed")
+            continue
+        log.info(f"Calibration pars are {pars}")
+        if deg == 1:
+            hit_dict[f"{energy_param}_cal"] = {
+                "expression": f"a*{energy_param}+b",
+                "parameters": {"a": pars[0], "b": pars[1]},
+            }
+        elif deg == 0:
+            hit_dict[f"{energy_param}_cal"] = {
+                "expression": f"a*{energy_param}",
+                "parameters": {"a": pars[0]},
+            }
+        elif deg == 2:
+            hit_dict[f"{energy_param}_cal"] = {
+                "expression": f"a*{energy_param}**2 +b*{energy_param}+c",
+                "parameters": {"a": pars[0], "b": pars[1], "c": pars[2]},
+            }
+        else:
+            hit_dict[f"{energy_param}_cal"] = {}
+            log.warning(f"hit_dict not implemented for deg = {deg}")
+        fitted_peaks = results["fitted_keV"]
+        fitted_funcs = []
+        fitted_gof_funcs = []
+        for i, peak in enumerate(glines):
+            if peak in fitted_peaks:
+                fitted_funcs.append(funcs[i])
+                fitted_gof_funcs.append(gof_funcs[i])
+
+        ecal_pass = pgf.poly(uncal_pass[energy_param], pars)
+        if cut_parameters is not None:
+            ecal_fail = pgf.poly(uncal_fail[energy_param], pars)
+        xpb = 1
+        xlo = 0
+        xhi = 4000
+        nb = int((xhi - xlo) / xpb)
+        hist_pass, bin_edges = np.histogram(ecal_pass, range=(xlo, xhi), bins=nb)
+        bins_pass = pgh.get_bin_centers(bin_edges)
+        fitted_peaks = results["fitted_keV"]
+        pk_pars = results["pk_pars"]
+        pk_covs = results["pk_covs"]
+
+        peaks_kev = results["got_peaks_keV"]
+
+        pk_ranges = results["pk_ranges"]
+        p_vals = results["pk_pvals"]
+        mus = [
+            pgf.get_mu_func(func_i, pars_i)
+            for func_i, pars_i in zip(fitted_funcs, pk_pars)
+        ]
+
+        fwhms = results["pk_fwhms"][:, 0]
+        dfwhms = results["pk_fwhms"][:, 1]
+
+        #####
+        # Remove the Tl SEP and DEP from calibration if found
+        fwhm_peaks = np.array([], dtype=np.float32)
+        indexes = []
+        for i, peak in enumerate(fitted_peaks):
+            if peak == 2103.53:
+                log.info(f"Tl SEP found at index {i}")
+                indexes.append(i)
                 continue
-            fitted_peaks = results['fitted_keV']
-            fitted_funcs = []
-            fitted_gof_funcs = []
-            for i, peak in enumerate(glines):
-                if peak in fitted_peaks:
-                    fitted_funcs.append(funcs[i])
-                    fitted_gof_funcs.append(gof_funcs[i])
-
-            ecal_pass = pgd.nb_poly(uncal_pass[energy_param], pars)
-            xpb = 1
-            xlo = 0
-            xhi = 4000
-            nb = int((xhi-xlo)/xpb)
-            hist_pass, bin_edges = np.histogram(ecal_pass, range=(xlo, xhi), bins=nb)
-            bins_pass = pgh.get_bin_centers(bin_edges)
-            fitted_peaks = results['fitted_keV']
-            pk_pars      = results['pk_pars']
-            pk_covs      = results['pk_covs']
-
-            plot_title = f'{detector}-{measurement}-{run}'
-            peaks_kev = results['got_peaks_keV']
-
-            pk_ranges = results['pk_ranges']
-            p_vals = results['pk_pvals']
-            mus = [pgds.get_mu_func(func_i, pars_i) for func_i, pars_i in zip(fitted_funcs, pk_pars)]
-
-            fwhms        = results['pk_fwhms'][:,0]
-            dfwhms       = results['pk_fwhms'][:,1]
-
-            plt.figure()
-            range_adu = 5/pars[0] #10keV window around peak in adu
-            for i, peak in enumerate(mus):
-                plt.subplot(math.ceil((len(mus))/2),2,i+1)
-                binning = np.arange(pk_ranges[i][0], pk_ranges[i][1], 1)
-                bin_cs = (binning[1:]+binning[:-1])/2
-                energies = uncal_pass[energy_param][(uncal_pass[energy_param]> pk_ranges[i][0])&
-                                            (uncal_pass[energy_param]< pk_ranges[i][1])][:n_events]
-
-                counts, bs, bars = plt.hist(energies, bins=binning, histtype='step')
-                fit_vals = fitted_gof_funcs[i](bin_cs, *pk_pars[i])*np.diff(bs)
-                plt.plot(bin_cs, fit_vals)
-                plt.step(bin_cs, [(fval-count)/count if count != 0 else  (fval-count) for count, fval in zip(counts, fit_vals)] )
-                plt.plot([bin_cs[10]],[0],label=get_peak_label(fitted_peaks[i]), linestyle='None' )
-                plt.plot([bin_cs[10]],[0],label = f'{fitted_peaks[i]:.1f} keV', linestyle='None')
-                plt.plot([bin_cs[10]],[0],label = f'{fwhms[i]:.2f} +- {dfwhms[i]:.2f} keV', linestyle='None')
-                plt.plot([bin_cs[10]],[0],label = f'p-value : {p_vals[i]:.2f}', linestyle='None')
-
-                plt.xlabel('Energy (keV)')
-                plt.ylabel('Counts')
-                plt.legend(loc = 'upper left', frameon=False)
-                plt.xlim([peak-range_adu, peak+range_adu])
-                locs,labels = plt.xticks()
-                new_locs, new_labels = get_peak_labels(locs, pars)
-                plt.xticks(ticks = new_locs, labels = new_labels)
-
-            plt.tight_layout()
-            pdf.savefig()
-            plt.close()
-
-            #####
-            # Remove the Tl SEP and DEP from calibration if found
-            fwhm_peaks   = np.array([], dtype=np.float32)
-            indexes=[]
-            for i,peak in enumerate(fitted_peaks):
-                if(peak==2103.53):
-                    print(f"Tl SEP found at index {i}")
-                    indexes.append(i)
-                    continue
-                elif(peak==1592.53):
-                    print(f"Tl DEP found at index {i}")
-                    indexes.append(i)
-                    continue
-                else:
-                    fwhm_peaks = np.append(fwhm_peaks,peak)
-            fwhms  = np.delete(fwhms,[indexes])
-            dfwhms = np.delete(dfwhms,[indexes])
-            mus = np.delete(mus,[indexes])
-            #####
-            plt.rcParams['figure.figsize'] = (12, 8)
-            plt.rcParams['font.size'] = 8
-            for i,peak in enumerate(fwhm_peaks):
-                print(f'FWHM of {peak} keV peak is: {fwhms[i]:1.2f} +- {dfwhms[i]:1.2f} keV')
-            param_guess  = [0.2,0.001]
-            param_bounds = (0, [10., 1.])
-            fit_pars, fit_covs = curve_fit(fwhm_slope, fwhm_peaks, fwhms, sigma=dfwhms,
-                                p0=param_guess, bounds=param_bounds, absolute_sigma=True)
-
-            rng = np.random.default_rng(1)
-            pars_b = rng.multivariate_normal(fit_pars, fit_covs, size=1000)
-            fits = np.array([fwhm_slope(fwhm_peaks, *pars) for pars in pars_b])
-            qbb_vals = np.array([fwhm_slope(2039.0, *pars) for pars in pars_b])
-            qbb_err = np.nanstd(qbb_vals)
-
-            print(f'FWHM curve fit: {fit_pars}')
-            fit_vals = fwhm_slope(fwhm_peaks,*fit_pars)
-            print(f'FWHM fit values: {fit_vals}')
-            fit_qbb = fwhm_slope(2039.0,*fit_pars)
-            print(f'FWHM energy resolution at Qbb: {fit_qbb:1.2f} +- {qbb_err:1.2f} keV' )
-            qbb_line_hx = [2039.0,2039.0]
-            qbb_line_hy = [np.amin(fwhms),fit_qbb]
-            qbb_line_vx = [np.amin(fwhm_peaks),2039.0]
-            qbb_line_vy = [fit_qbb,fit_qbb]
-            fig, (ax1, ax2) = plt.subplots(2, 1, constrained_layout=True, sharex=True)
-            ax1.errorbar(fwhm_peaks,fwhms,yerr=dfwhms, marker='x',lw=0, c='b')
-            ax1.plot(fwhm_peaks,fit_vals,ls=' ')
-            fwhm_slope_bins = np.arange(np.amin(fwhm_peaks),np.amax(fwhm_peaks),10)
-            ax1.plot(fwhm_slope_bins ,fwhm_slope(fwhm_slope_bins,*fit_pars),lw=1, c='g')
-            ax1.plot(qbb_line_hx,qbb_line_hy,lw=1, c='r')
-            ax1.plot(qbb_line_vx,qbb_line_vy,lw=1, c='r')
-            ax1.set_ylim([1,3])
-            ax1.set_ylabel("FWHM energy resolution (keV)", ha='right', y=1)
-            ax2.plot(fwhm_peaks,pgd.nb_poly(mus, pars)-fwhm_peaks, lw=1, c='b')
-            ax2.set_xlabel("Energy (keV)",    ha='right', x=1)
-            ax2.set_ylabel("Residuals (keV)", ha='right', y=1)
-            fig.suptitle(plot_title)
-            pdf.savefig()
-            plt.close()
-
-
-
-        output_dict[energy_param] = {'Qbb_fwhm': round(fit_qbb,2), 'Qbb_fwhm_err': round(qbb_err,2),
-                                    '2.6_fwhm': round(fwhms[-1],2), '2.6_fwhm_err': round(dfwhms[-1],2),
-                                    "m0":fit_pars[0], "m1":fit_pars[1],
-                                    "Calibration_pars":pars.tolist(),
-                                    "Number_events": Nevents}
-
-
-    dict_save_path = os.path.join(save_path, f'{detector}.json')
-    with open(dict_save_path,'w') as fp:
-        json.dump(output_dict,fp, indent=4)
-
-def get_peak_labels(labels, pars):
+            elif peak == 1592.53:
+                log.info(f"Tl DEP found at index {i}")
+                indexes.append(i)
+                continue
+            elif np.isnan(dfwhms[i]):
+                log.info(f"{peak} failed")
+                indexes.append(i)
+                continue
+            else:
+                fwhm_peaks = np.append(fwhm_peaks, peak)
+        fit_fwhms = np.delete(fwhms, [indexes])
+        fit_dfwhms = np.delete(dfwhms, [indexes])
+        fit_mus = np.delete(mus, [indexes])
+        #####
+
+        for i, peak in enumerate(fwhm_peaks):
+            log.info(
+                f"FWHM of {peak} keV peak is: {fit_fwhms[i]:1.2f} +- {fit_dfwhms[i]:1.2f} keV"
+            )
+        param_guess = [0.2, 0.001]
+        param_bounds = (0, [10.0, 1.0])
+        fit_pars, fit_covs = curve_fit(
+            fwhm_slope,
+            fwhm_peaks,
+            fit_fwhms,
+            sigma=fit_dfwhms,
+            p0=param_guess,
+            bounds=param_bounds,
+            absolute_sigma=True,
+        )
+
+        rng = np.random.default_rng(1)
+        pars_b = rng.multivariate_normal(fit_pars, fit_covs, size=1000)
+        fits = np.array([fwhm_slope(fwhm_peaks, *par_b) for par_b in pars_b])
+        qbb_vals = np.array([fwhm_slope(2039.0, *par_b) for par_b in pars_b])
+        qbb_err = np.nanstd(qbb_vals)
+
+        log.info(f"FWHM curve fit: {fit_pars}")
+        predicted_fwhms = fwhm_slope(fwhm_peaks, *fit_pars)
+        log.info(f"FWHM fit values: {predicted_fwhms}")
+        fit_qbb = fwhm_slope(2039.0, *fit_pars)
+        log.info(f"FWHM energy resolution at Qbb: {fit_qbb:1.2f} +- {qbb_err:1.2f} keV")
+
+        if plot_path is not None:
+
+            mpl.use("pdf")
+            plt.rcParams["figure.figsize"] = (12, 20)
+            plt.rcParams["font.size"] = 12
+
+            pathlib.Path(os.path.dirname(plot_path)).mkdir(parents=True, exist_ok=True)
+
+            with PdfPages(plot_path) as pdf:
+
+                plt.figure()
+                range_adu = 5 / pars[0]  # 10keV window around peak in adu
+                for i, peak in enumerate(mus):
+                    plt.subplot(math.ceil((len(mus)) / 2), 2, i + 1)
+                    binning = np.arange(pk_ranges[i][0], pk_ranges[i][1], 1)
+                    bin_cs = (binning[1:] + binning[:-1]) / 2
+                    energies = uncal_pass[energy_param][
+                        (uncal_pass[energy_param] > pk_ranges[i][0])
+                        & (uncal_pass[energy_param] < pk_ranges[i][1])
+                    ][:n_events]
+
+                    counts, bs, bars = plt.hist(energies, bins=binning, histtype="step")
+                    fit_vals = fitted_gof_funcs[i](bin_cs, *pk_pars[i]) * np.diff(bs)
+                    plt.plot(bin_cs, fit_vals)
+                    plt.step(
+                        bin_cs,
+                        [
+                            (fval - count) / count if count != 0 else (fval - count)
+                            for count, fval in zip(counts, fit_vals)
+                        ],
+                    )
+                    plt.plot(
+                        [bin_cs[10]],
+                        [0],
+                        label=get_peak_label(fitted_peaks[i]),
+                        linestyle="None",
+                    )
+                    plt.plot(
+                        [bin_cs[10]],
+                        [0],
+                        label=f"{fitted_peaks[i]:.1f} keV",
+                        linestyle="None",
+                    )
+                    plt.plot(
+                        [bin_cs[10]],
+                        [0],
+                        label=f"{fwhms[i]:.2f} +- {dfwhms[i]:.2f} keV",
+                        linestyle="None",
+                    )
+                    plt.plot(
+                        [bin_cs[10]],
+                        [0],
+                        label=f"p-value : {p_vals[i]:.2f}",
+                        linestyle="None",
+                    )
+
+                    plt.xlabel("Energy (keV)")
+                    plt.ylabel("Counts")
+                    plt.legend(loc="upper left", frameon=False)
+                    plt.xlim([peak - range_adu, peak + range_adu])
+                    locs, labels = plt.xticks()
+                    new_locs, new_labels = get_peak_labels(locs, pars)
+                    plt.xticks(ticks=new_locs, labels=new_labels)
+
+                plt.tight_layout()
+                pdf.savefig()
+                plt.close()
+
+                plt.rcParams["figure.figsize"] = (12, 8)
+                plt.rcParams["font.size"] = 8
+
+                qbb_line_hx = [2039.0, 2039.0]
+                qbb_line_hy = [np.amin(fit_fwhms), fit_qbb]
+                qbb_line_vx = [np.amin(fwhm_peaks), 2039.0]
+                qbb_line_vy = [fit_qbb, fit_qbb]
+
+                fig, (ax1, ax2) = plt.subplots(
+                    2, 1, constrained_layout=True, sharex=True
+                )
+                ax1.errorbar(
+                    fwhm_peaks, fit_fwhms, yerr=fit_dfwhms, marker="x", lw=0, c="b"
+                )
+                ax1.plot(fwhm_peaks, predicted_fwhms, ls=" ")
+                fwhm_slope_bins = np.arange(
+                    np.amin(fwhm_peaks), np.amax(fwhm_peaks), 10
+                )
+                ax1.plot(
+                    fwhm_slope_bins, fwhm_slope(fwhm_slope_bins, *fit_pars), lw=1, c="g"
+                )
+                ax1.plot(qbb_line_hx, qbb_line_hy, lw=1, c="r")
+                ax1.plot(qbb_line_vx, qbb_line_vy, lw=1, c="r")
+                ax1.plot(
+                    np.nan,
+                    np.nan,
+                    "-",
+                    color="none",
+                    label=f"Qbb fwhm: {fit_qbb:1.2f} +- {qbb_err:1.2f} keV",
+                )
+                ax1.legend(loc="upper left", frameon=False)
+                ax1.set_ylim(
+                    [1, 1.1 * np.nanmax(fwhm_slope(fwhm_slope_bins, *fit_pars))]
+                )
+                ax1.set_ylabel("FWHM energy resolution (keV)", ha="right", y=1)
+                ax2.plot(fwhm_peaks, pgf.poly(fit_mus, pars) - fwhm_peaks, lw=1, c="b")
+                ax2.set_xlabel("Energy (keV)", ha="right", x=1)
+                ax2.set_ylabel("Residuals (keV)", ha="right", y=1)
+                pdf.savefig()
+                plt.close()
+
+                plt.figure()
+                bins = np.linspace(0, 3000, 6000)
+                plt.hist(
+                    ecal_pass,
+                    bins=bins,
+                    histtype="step",
+                    label=f"{len(ecal_pass)} events passed quality cuts",
+                )
+                if cut_parameters is not None:
+                    plt.hist(
+                        ecal_fail,
+                        bins=bins,
+                        histtype="step",
+                        label=f"{len(ecal_fail)} events failed quality cuts",
+                    )
+                plt.yscale("log")
+                plt.xlabel("Energy (keV)")
+                plt.ylabel("Counts")
+                plt.legend(loc="upper right")
+                pdf.savefig()
+                plt.close()
+
+                if cut_parameters is not None:
+                    plt.figure()
+                    n_bins = 500
+                    counts_pass, bins_pass, _ = pgh.get_hist(
+                        ecal_pass, bins=n_bins, range=(0, 3000)
+                    )
+                    counts_fail, bins_fail, _ = pgh.get_hist(
+                        ecal_fail, bins=n_bins, range=(0, 3000)
+                    )
+                    sf = 100 * counts_pass / (counts_pass + counts_fail)
+
+                    plt.step(pgh.get_bin_centers(bins_pass), sf)
+                    plt.xlabel("Energy (keV)")
+                    plt.ylabel("Survival Fraction (%)")
+                    plt.ylim([0, 100])
+                    pdf.savefig()
+                    plt.close()
+
+        if fitted_peaks[-1] == 2614.50:
+            fep_fwhm = round(fwhms[-1], 2)
+            fep_dwhm = round(dfwhms[-1], 2)
+        else:
+            fep_fwhm = np.nan
+            fep_dwhm = np.nan
+
+        output_dict[f"{energy_param}_cal"] = {
+            "Qbb_fwhm": round(fit_qbb, 2),
+            "Qbb_fwhm_err": round(qbb_err, 2),
+            "2.6_fwhm": fep_fwhm,
+            "2.6_fwhm_err": fep_dwhm,
+            "eres_pars": fit_pars.tolist(),
+        }
+
+    log.info(f"Finished : {hit_dict}")
+    return hit_dict, output_dict
+
+
+def get_peak_labels(
+    labels: list[str], pars: list[float]
+) -> tuple(list[float], list[float]):
     out = []
     out_labels = []
-    for i,label in enumerate(labels):
-        if i%2 == 1:
+    for i, label in enumerate(labels):
+        if i % 2 == 1:
             continue
         else:
-            out.append( f'{pgd.nb_poly(label, pars):.1f}')
+            out.append(f"{pgf.poly(label, pars):.1f}")
             out_labels.append(label)
     return out_labels, out
 
-def get_peak_label(peak):
+
+def get_peak_label(peak: float) -> str:
     if peak == 583.191:
-        return 'Tl 583'
+        return "Tl 583"
     elif peak == 727.33:
-        return 'Bi 727'
+        return "Bi 727"
     elif peak == 860.564:
-        return 'Tl 860'
+        return "Tl 860"
     elif peak == 1592.53:
-        return 'Tl DEP'
+        return "Tl DEP"
     elif peak == 1620.5:
-        return 'Bi FEP'
+        return "Bi FEP"
     elif peak == 2103.53:
-        return 'Tl SEP'
+        return "Tl SEP"
     elif peak == 2614.5:
-        return 'Tl FEP'
+        return "Tl FEP"
diff --git a/src/pygama/pargen/energy_cal.py b/src/pygama/pargen/energy_cal.py
index 9ab82e95d..0acb0517d 100644
--- a/src/pygama/pargen/energy_cal.py
+++ b/src/pygama/pargen/energy_cal.py
@@ -5,6 +5,7 @@
 - hpge_fit_E_peaks (fits the energy peals)
 - hpge_E_calibration (main routine -- finds and fits peaks specified)
 """
+import logging
 import sys
 
 import matplotlib.gridspec as gs
@@ -20,8 +21,20 @@
 import pygama.math.least_squares as pgl
 import pygama.math.unbinned_fitting as pgub
 
+log = logging.getLogger(__name__)
 
-def hpge_find_E_peaks(hist, bins, var, peaks_keV, n_sigma=5, deg=0, Etol_keV=None, var_zero=1, verbose=False):
+
+def hpge_find_E_peaks(
+    hist,
+    bins,
+    var,
+    peaks_keV,
+    n_sigma=5,
+    deg=0,
+    Etol_keV=None,
+    var_zero=1,
+    verbose=False,
+):
     """Find uncalibrated E peaks whose E spacing matches the pattern in peaks_keV
     Note: the specialization here to units "keV" in peaks and Etol is
     unnecessary. However it is kept so that the default value for Etol_keV has
@@ -58,33 +71,54 @@ def hpge_find_E_peaks(hist, bins, var, peaks_keV, n_sigma=5, deg=0, Etol_keV=Non
     """
     # clean up var if necessary
     if np.any(var == 0):
-        if verbose:
-            print(f'hpge_find_E_peaks: replacing var zeros with {var_zero}')
+        log.debug(f"hpge_find_E_peaks: replacing var zeros with {var_zero}")
         var[np.where(var == 0)] = var_zero
     peaks_keV = np.asarray(peaks_keV)
 
     # Find all maxes with > n_sigma significance
-    imaxes = get_i_local_maxima(hist/np.sqrt(var), n_sigma)
+    imaxes = get_i_local_maxima(hist / np.sqrt(var), n_sigma)
 
     # Now pattern match to peaks_keV within Etol_keV using poly_match
     detected_max_locs = pgh.get_bin_centers(bins)[imaxes]
 
     if Etol_keV is None:
-        #estimate Etol_keV
-        pt_pars, pt_covs = hpge_fit_E_peak_tops(hist, bins, var, detected_max_locs, n_to_fit=15)
-        if sum(sum(sum(c) if c is not None else 0 for c in pt_covs)) == np.inf or sum(sum(sum(c) if c is not None else 0 for c in pt_covs)) == 0: print('hpge_find_E_peaks: can safely ignore previous covariance warning, not used')
+        # estimate Etol_keV
+        pt_pars, pt_covs = hpge_fit_E_peak_tops(
+            hist, bins, var, detected_max_locs, n_to_fit=15
+        )
+        if (
+            sum(sum(sum(c) if (c != None).any() else 0 for c in pt_covs)) == np.inf
+            or sum(sum(sum(c) if (c != None).any() else 0 for c in pt_covs)) == 0
+        ):
+            print(
+                "hpge_find_E_peaks: can safely ignore previous covariance warning, not used"
+            )
         pt_pars = pt_pars[np.array([x is not None for x in pt_pars])]
-        med_sigma_ratio = np.median(np.stack(pt_pars)[:,1]/np.stack(pt_pars)[:,0])
+        med_sigma_ratio = np.median(np.stack(pt_pars)[:, 1] / np.stack(pt_pars)[:, 0])
 
-        Etol_keV = 5. * (med_sigma_ratio / 0.003)
-    pars, ixtup, iytup = poly_match(detected_max_locs, peaks_keV, deg=deg, atol=Etol_keV)
+        Etol_keV = 5.0 * (med_sigma_ratio / 0.003)
+    pars, ixtup, iytup = poly_match(
+        detected_max_locs, peaks_keV, deg=deg, atol=Etol_keV
+    )
 
-    if verbose and len(ixtup) != len(peaks_keV):
-        print(f'hpge_find_E_peaks: only found {len(ixtup)} of {len(peaks_keV)} expected peaks')
+    if len(ixtup) != len(peaks_keV):
+        log.warning(
+            f"hpge_find_E_peaks: only found {len(ixtup)} of {len(peaks_keV)} expected peaks"
+        )
     return detected_max_locs[ixtup], peaks_keV[iytup], pars
 
 
-def hpge_get_E_peaks(hist, bins, var, cal_pars, peaks_keV, n_sigma=3, Etol_keV=5, var_zero=1, verbose = False):
+def hpge_get_E_peaks(
+    hist,
+    bins,
+    var,
+    cal_pars,
+    peaks_keV,
+    n_sigma=3,
+    Etol_keV=5,
+    var_zero=1,
+    verbose=False,
+):
     """Get uncalibrated E peaks at the energies of peaks_keV
 
     Parameters
@@ -118,13 +152,12 @@ def hpge_get_E_peaks(hist, bins, var, cal_pars, peaks_keV, n_sigma=3, Etol_keV=5
     """
     # clean up var if necessary
     if np.any(var == 0):
-        if verbose:
-            print(f'hpge_find_E_peaks: replacing var zeros with {var_zero}')
+        log.debug(f"hpge_find_E_peaks: replacing var zeros with {var_zero}")
         var[np.where(var == 0)] = var_zero
     peaks_keV = np.asarray(peaks_keV)
 
     # Find all maxes with > n_sigma significance
-    imaxes = get_i_local_maxima(hist/np.sqrt(var), n_sigma)
+    imaxes = get_i_local_maxima(hist / np.sqrt(var), n_sigma)
 
     # Keep maxes if they coincide with expected peaks
     test_peaks_keV = np.asarray([pgd.nb_poly(i, cal_pars) for i in bins[imaxes]])
@@ -134,20 +167,24 @@ def hpge_get_E_peaks(hist, bins, var, cal_pars, peaks_keV, n_sigma=3, Etol_keV=5
     got_peak_energies = test_peaks_keV[imatch]
 
     # Match calculated and true peak energies
-    matched_energies = peaks_keV[[np.argmin(abs(peaks_keV - i)) for i in got_peak_energies]]
+    matched_energies = peaks_keV[
+        [np.argmin(abs(peaks_keV - i)) for i in got_peak_energies]
+    ]
     while not all([list(matched_energies).count(x) == 1 for x in matched_energies]):
         for i in range(len(matched_energies)):
-            if matched_energies[i+1] == matched_energies[i]:
-                #remove duplicates
-                if np.argmin(abs(got_peak_energies[i:i+2] - matched_energies[i])): #i+1 is best match
+            if matched_energies[i + 1] == matched_energies[i]:
+                # remove duplicates
+                if np.argmin(
+                    abs(got_peak_energies[i : i + 2] - matched_energies[i])
+                ):  # i+1 is best match
                     got_peak_locations = np.delete(got_peak_locations, i)
                     got_peak_energies = np.delete(got_peak_energies, i)
-                else: #i is best match
-                    got_peak_locations = np.delete(got_peak_locations, i+1)
-                    got_peak_energies = np.delete(got_peak_energies, i+1)
+                else:  # i is best match
+                    got_peak_locations = np.delete(got_peak_locations, i + 1)
+                    got_peak_energies = np.delete(got_peak_energies, i + 1)
                 matched_energies = np.delete(matched_energies, i)
                 break
-            i+=1
+            i += 1
 
     # Calculate updated calibration curve
     pars = np.polyfit(got_peak_locations, matched_energies, len(cal_pars))
@@ -155,8 +192,16 @@ def hpge_get_E_peaks(hist, bins, var, cal_pars, peaks_keV, n_sigma=3, Etol_keV=5
     return got_peak_locations, matched_energies, pars
 
 
-def hpge_fit_E_peak_tops(hist, bins, var, peak_locs, n_to_fit=7,
-                         cost_func = 'Least Squares', inflate_errors=False, gof_method='var'):
+def hpge_fit_E_peak_tops(
+    hist,
+    bins,
+    var,
+    peak_locs,
+    n_to_fit=7,
+    cost_func="Least Squares",
+    inflate_errors=False,
+    gof_method="var",
+):
     """Fit gaussians to the tops of peaks
 
     Parameters
@@ -185,17 +230,22 @@ def hpge_fit_E_peak_tops(hist, bins, var, peak_locs, n_to_fit=7,
     cov_list = []
     for E_peak in peak_locs:
         try:
-            pars, cov = pgb.gauss_mode_width_max(hist, bins, var,
-                                                 mode_guess=E_peak,
-                                                 n_bins=n_to_fit,
-                                                 cost_func=cost_func,
-                                                 inflate_errors=inflate_errors,
-                                                 gof_method=gof_method)
-        except: pars, cov = None, None
+            pars, cov = pgf.gauss_mode_width_max(
+                hist,
+                bins,
+                var,
+                mode_guess=E_peak,
+                n_bins=n_to_fit,
+                cost_func=cost_func,
+                inflate_errors=inflate_errors,
+                gof_method=gof_method,
+            )
+        except:
+            pars, cov = None, None
 
         pars_list.append(pars)
         cov_list.append(cov)
-    return np.array(pars_list, dtype = object), np.array(cov_list, dtype = object)
+    return np.array(pars_list, dtype=object), np.array(cov_list, dtype=object)
 
 
 def get_hpge_E_peak_par_guess(hist, bins, var, func):
@@ -209,16 +259,20 @@ def get_hpge_E_peak_par_guess(hist, bins, var, func):
     func : function
         The function to be fit to the peak in the (windowed) hist
     """
-    if  func == pgd.nb_gauss_step_cdf or func == pgd.nb_gauss_step_pdf or func == pgd.nb_gauss_step:
+    if (
+        func == pgf.gauss_step_cdf
+        or func == pgf.gauss_step_pdf
+        or func == pgf.extended_gauss_step_pdf
+    ):
         # get mu and height from a gauss fit, also sigma as fallback
         pars, cov = pgb.gauss_mode_width_max(hist, bins, var)
         bin_centres = pgh.get_bin_centers(bins)
         if pars is None:
-            print("get_hpge_E_peak_par_guess: gauss_mode_width_max failed")
+            log.warning("get_hpge_E_peak_par_guess: gauss_mode_width_max failed")
             i_0 = np.argmax(hist)
             mu = bin_centres[i_0]
             height = hist[i_0]
-            sigma_guess=None
+            sigma_guess = None
         else:
             mu = pars[0]
             sigma_guess = pars[1]
@@ -226,114 +280,204 @@ def get_hpge_E_peak_par_guess(hist, bins, var, func):
 
         # get bg and step from edges of hist
         bg = np.mean(hist[-10:])
-        step = (bg - np.mean(hist[:10]))
+        step = bg - np.mean(hist[:10])
         # get sigma from fwfm with f = 1/sqrt(e)
         try:
-            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg-step/2, method='interpolate')[0]
-            if sigma == 0: raise ValueError
+            sigma = pgh.get_fwfm(
+                0.6065,
+                hist,
+                bins,
+                var,
+                mx=height,
+                bl=bg - step / 2,
+                method="interpolate",
+            )[0]
+            if sigma == 0:
+                raise ValueError
         except:
-            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg-step/2, method='fit_slopes')[0]
+            sigma = pgh.get_fwfm(
+                0.6065,
+                hist,
+                bins,
+                var,
+                mx=height,
+                bl=bg - step / 2,
+                method="fit_slopes",
+            )[0]
             if sigma == 0:
-                print("get_hpge_E_peak_par_guess: sigma estimation failed")
+                log.warning("get_hpge_E_peak_par_guess: sigma estimation failed")
                 if sigma_guess is not None:
                     sigma = sigma_guess
                 else:
                     return []
 
         # now compute amp and return
-        n_sig = np.sum(hist[(bin_centres>mu-3*sigma)&(bin_centres<mu+3*sigma)])
-        n_bkg = np.sum(hist)-n_sig
+        n_sig = np.sum(
+            hist[(bin_centres > mu - 3 * sigma) & (bin_centres < mu + 3 * sigma)]
+        )
+        n_bkg = np.sum(hist) - n_sig
 
-        hstep = step /(bg + np.mean(hist[:10]))
-        return [n_sig, mu, sigma/2,n_bkg, hstep,bins[0], bins[-1],0]
+        hstep = step / (bg + np.mean(hist[:10]))
+        return [n_sig, mu, sigma / 2, n_bkg, hstep, bins[0], bins[-1], 0]
 
-    if  func == pgd.nb_hpge_peak_cdf or func == pgd.nb_hpge_peak_pdf or func == pgd.nb_extended_hpge_peak_pdf:
+    if (
+        func == pgf.radford_cdf
+        or func == pgf.radford_pdf
+        or func == pgf.extended_radford_pdf
+    ):
 
-        #guess mu, height
-        pars, cov = pgb.gauss_mode_width_max(hist, bins, var)
+        # guess mu, height
+        pars, cov = pgf.gauss_mode_width_max(hist, bins, var)
         bin_centres = pgh.get_bin_centers(bins)
         if pars is None:
-            print("get_hpge_E_peak_par_guess: gauss_mode_width_max failed")
-            sigma_guess=None
+            log.warning("get_hpge_E_peak_par_guess: gauss_mode_width_max failed")
+            sigma_guess = None
 
         else:
             sigma_guess = pars[1]
-            #mu=pars[0]
-            #height=pars[2]
+            # mu=pars[0]
+            # height=pars[2]
         i_0 = np.argmax(hist)
         mu = bin_centres[i_0]
         height = hist[i_0]
 
         # get bg and step from edges of hist
         bg0 = np.mean(hist[-10:])
-        step = bg0 -np.mean(hist[:10])
+        step = bg0 - np.mean(hist[:10])
 
         # get sigma from fwfm with f = 1/sqrt(e)
         try:
-            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg0+step/2, method='interpolate')[0]
-            if sigma == 0: raise ValueError
+            sigma = pgh.get_fwfm(
+                0.6065,
+                hist,
+                bins,
+                var,
+                mx=height,
+                bl=bg0 + step / 2,
+                method="interpolate",
+            )[0]
+            if sigma == 0:
+                raise ValueError
         except:
-            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg0+step/2, method='fit_slopes')[0]
+            sigma = pgh.get_fwfm(
+                0.6065,
+                hist,
+                bins,
+                var,
+                mx=height,
+                bl=bg0 + step / 2,
+                method="fit_slopes",
+            )[0]
             if sigma == 0:
-                print("get_hpge_E_peak_par_guess: sigma estimation failed")
+                log.warning("get_hpge_E_peak_par_guess: sigma estimation failed")
                 if sigma_guess is not None:
                     sigma = sigma_guess
                 else:
                     return []
-        sigma = sigma*.6 # roughly remove some amount due to tail
+        sigma = sigma * 0.6  # roughly remove some amount due to tail
 
         # for now hard-coded
-        htail = 1./5
-        tau = 0.5*sigma
+        htail = 1.0 / 5
+        tau = 0.5 * sigma
 
-        hstep = step /(bg0 + np.mean(hist[:10]))
+        hstep = step / (bg0 + np.mean(hist[:10]))
 
-        n_sig = np.sum(hist[(bin_centres>mu-3*sigma)&(bin_centres<mu+3*sigma)])
-        n_bkg = np.sum(hist)-n_sig
+        n_sig = np.sum(
+            hist[(bin_centres > mu - 3 * sigma) & (bin_centres < mu + 3 * sigma)]
+        )
+        n_bkg = np.sum(hist) - n_sig
 
-        parguess = [n_sig,  mu, sigma, htail, tau, n_bkg,hstep,bins[0], bins[-1],0]
+        parguess = [n_sig, mu, sigma, htail, tau, n_bkg, hstep, bins[0], bins[-1], 0]
 
         return parguess
 
     else:
-        print(f'get_hpge_E_peak_par_guess not implemented for {func.__name__}')
+        log.error(f"get_hpge_E_peak_par_guess not implemented for {func.__name__}")
         return []
 
 
-def get_hpge_E_fixed( func):
+def get_hpge_E_fixed(func):
     """
     Returns: Sequence list of fixed indexes for fitting and mask for parameters
     """
 
-    if  func ==  pgd.nb_gauss_step_cdf or func ==  pgd.nb_gauss_step_pdf or func == pgd.nb_gauss_step :
+    if (
+        func == pgf.gauss_step_cdf
+        or func == pgf.gauss_step_pdf
+        or func == pgf.extended_gauss_step_pdf
+    ):
         # pars are: n_sig, mu, sigma, n_bkg, hstep, components
-        return [5,6,7] , np.array([True, True, True,True,True,False,False,False])
+        return [5, 6, 7], np.array([True, True, True, True, True, False, False, False])
 
-    if  func == pgd.nb_hpge_peak_cdf or func == pgd.nb_hpge_peak_pdf or func == pgd.nb_extended_hpge_peak_pdf:
+    if (
+        func == pgf.radford_cdf
+        or func == pgf.radford_pdf
+        or func == pgf.extended_radford_pdf
+    ):
         # pars are: n_sig, mu, sigma, htail,tau, n_bkg, hstep, components
-        return [7,8,9], np.array([True, True, True,True,True,True,True,False,False,False])
+        return [7, 8, 9], np.array(
+            [True, True, True, True, True, True, True, False, False, False]
+        )
 
     else:
-        print(f'get_hpge_E_fixed not implemented for {func.__name__}')
+        log.error(f"get_hpge_E_fixed not implemented for {func.__name__}")
         return None
     return None
 
-def get_hpge_E_bounds(func):
-    if  func == pgd.nb_hpge_peak_cdf or func == pgd.nb_hpge_peak_pdf or func == pgd.nb_extended_hpge_peak_pdf:
-        return [(0,None), (None,None), (None,None), (0,1),(None,None),(0,None), (None,None)
-                ,(None,None),(None,None),(None,None)]
 
-    elif  func ==  pgd.nb_gauss_step_cdf or func ==  pgd.nb_gauss_step_pdf or func == pgd.nb_gauss_step :
-        return [(0,None), (None,None), (None,None),(0,None), (None,None),(None,None)
-                ,(None,None),(None,None)]
+def get_hpge_E_bounds(func):
+    if (
+        func == pgf.radford_cdf
+        or func == pgf.radford_pdf
+        or func == pgf.extended_radford_pdf
+    ):
+        return [
+            (0, None),
+            (None, None),
+            (None, None),
+            (0, 1),
+            (0, None),
+            (0, None),
+            (None, None),
+            (None, None),
+            (None, None),
+            (None, None),
+        ]
+
+    elif (
+        func == pgf.gauss_step_cdf
+        or func == pgf.gauss_step_pdf
+        or func == pgf.extended_gauss_step_pdf
+    ):
+        return [
+            (0, None),
+            (None, None),
+            (None, None),
+            (0, None),
+            (None, None),
+            (None, None),
+            (None, None),
+            (None, None),
+        ]
 
     else:
-        print(f'get_hpge_E_bounds not implemented for {func.__name__}')
+        log.error(f"get_hpge_E_bounds not implemented for {func.__name__}")
         return []
 
-def hpge_fit_E_peaks(E_uncal, mode_guesses, wwidths, n_bins=50, funcs=pgd.nb_gauss_step_cdf,
-                     method = 'unbinned', gof_funcs=None, n_events=15000, allowed_p_val= 0.05,
-                     uncal_is_int=False, simplex=False):
+
+def hpge_fit_E_peaks(
+    E_uncal,
+    mode_guesses,
+    wwidths,
+    n_bins=50,
+    funcs=pgf.gauss_step_cdf,
+    method="unbinned",
+    gof_funcs=None,
+    n_events=15000,
+    allowed_p_val=0.05,
+    uncal_is_int=False,
+    simplex=False,
+):
     """Fit the Energy peaks specified using the function given
 
     Parameters
@@ -375,18 +519,20 @@ def hpge_fit_E_peaks(E_uncal, mode_guesses, wwidths, n_bins=50, funcs=pgd.nb_gau
     covs = []
     binws = []
     ranges = []
-    errors=[]
+    errors = []
     p_vals = []
 
     for i_peak in range(len(mode_guesses)):
         # get args for this peak
         wwidth_i = wwidths if not isinstance(wwidths, list) else wwidths[i_peak]
         n_bins_i = n_bins if np.isscalar(n_bins) else n_bins[i_peak]
-        func_i = funcs[i_peak] if hasattr(funcs, '__len__') else funcs
-        wleft_i = wwidth_i/2 if np.isscalar(wwidth_i) else wwidth_i[0]
-        wright_i = wwidth_i/2 if np.isscalar(wwidth_i) else wwidth_i[1]
+        func_i = funcs[i_peak] if hasattr(funcs, "__len__") else funcs
+        wleft_i = wwidth_i / 2 if np.isscalar(wwidth_i) else wwidth_i[0]
+        wright_i = wwidth_i / 2 if np.isscalar(wwidth_i) else wwidth_i[1]
         if gof_funcs is not None:
-            gof_func_i = gof_funcs[i_peak] if hasattr(gof_funcs, '__len__') else gof_funcs
+            gof_func_i = (
+                gof_funcs[i_peak] if hasattr(gof_funcs, "__len__") else gof_funcs
+            )
         else:
             gof_func_i = func_i
 
@@ -394,57 +540,91 @@ def hpge_fit_E_peaks(E_uncal, mode_guesses, wwidths, n_bins=50, funcs=pgd.nb_gau
             # bin a histogram
             Euc_min = mode_guesses[i_peak] - wleft_i
             Euc_max = mode_guesses[i_peak] + wright_i
-            Euc_min, Euc_max, n_bins_i = pgh.better_int_binning(x_lo=Euc_min, x_hi=Euc_max, n_bins=n_bins_i)
-
-            if method =='unbinned':
-                energies = E_uncal[(E_uncal>Euc_min)&(E_uncal<Euc_max)][:n_events]
-                hist, bins, var = pgh.get_hist(energies, bins=n_bins_i, range=(Euc_min,Euc_max))
+            Euc_min, Euc_max, n_bins_i = pgh.better_int_binning(
+                x_lo=Euc_min, x_hi=Euc_max, n_bins=n_bins_i
+            )
+
+            if method == "unbinned":
+                energies = E_uncal[(E_uncal > Euc_min) & (E_uncal < Euc_max)][:n_events]
+                hist, bins, var = pgh.get_hist(
+                    energies, bins=n_bins_i, range=(Euc_min, Euc_max)
+                )
                 par_guesses = get_hpge_E_peak_par_guess(hist, bins, var, func_i)
                 bounds = get_hpge_E_bounds(func_i)
                 fixed, mask = get_hpge_E_fixed(func_i)
 
-                pars_i,errs_i, cov_i = pgub.fit_unbinned(func_i,energies ,
-                                        guess=par_guesses, Extended=True, fixed=fixed,simplex=simplex, bounds= bounds)
+                pars_i, errs_i, cov_i = pgf.fit_unbinned(
+                    func_i,
+                    energies,
+                    guess=par_guesses,
+                    Extended=True,
+                    fixed=fixed,
+                    simplex=simplex,
+                    bounds=bounds,
+                )
             else:
-                hist, bins, var = pgh.get_hist(E_uncal, bins=n_bins_i, range=(Euc_min,Euc_max))
+                hist, bins, var = pgh.get_hist(
+                    E_uncal, bins=n_bins_i, range=(Euc_min, Euc_max)
+                )
                 par_guesses = get_hpge_E_peak_par_guess(hist, bins, var, func_i)
                 bounds = get_hpge_E_bounds(func_i)
                 fixed, mask = get_hpge_E_fixed(func_i)
-                pars_i,errs_i, cov_i = pgb.fit_binned(func_i, hist,  bins, var=var,
-                                        guess=par_guesses, cost_func=method, Extended=True,
-                                                    fixed=fixed, simplex=simplex, bounds = bounds)
-
-            csqr = pgb.goodness_of_fit(hist, bins, None, gof_func_i, pars_i , method='LR')
+                pars_i, errs_i, cov_i = pgf.fit_binned(
+                    func_i,
+                    hist,
+                    bins,
+                    var=var,
+                    guess=par_guesses,
+                    cost_func=method,
+                    Extended=True,
+                    fixed=fixed,
+                    simplex=simplex,
+                    bounds=bounds,
+                )
+
+            csqr = pgf.goodness_of_fit(
+                hist, bins, None, gof_func_i, pars_i, method="LR"
+            )
             p_val = scipy.stats.chi2.sf(csqr[0], csqr[1])
 
-
             pars_i = np.array(pars_i)[mask]
             errs_i = np.array(errs_i)[mask]
-            cov_i = np.array(cov_i)[mask,:][:,mask]
-
-            total_events = pgds.get_total_events_func(func_i, pars_i, errors=errs_i)
-            if (sum(sum(c) if c is not None else 0 for c in cov_i) == np.inf or
-                sum(sum(c) if c is not None else 0 for c in cov_i) == 0 or
-                np.isnan(sum(sum(c) if c is not None else 0 for c in cov_i))) :
-                print(f'hpge_fit_E_peaks: cov estimation failed for i_peak={i_peak} at loc {mode_guesses[i_peak]:g}')
+            cov_i = np.array(cov_i)[mask, :][:, mask]
+
+            total_events = pgf.get_total_events_func(func_i, pars_i, errors=errs_i)
+            if (
+                sum(sum(c) if c is not None else 0 for c in cov_i) == np.inf
+                or sum(sum(c) if c is not None else 0 for c in cov_i) == 0
+                or np.isnan(sum(sum(c) if c is not None else 0 for c in cov_i))
+            ):
+                log.debug(
+                    f"hpge_fit_E_peaks: cov estimation failed for i_peak={i_peak} at loc {mode_guesses[i_peak]:g}"
+                )
                 pars_i, errs_i, cov_i, p_val = None, None, None, None
 
-            elif (np.abs(errs_i/pars_i) < 1e-7).any():
-                print(f'hpge_fit_E_peaks: cov estimation failed for i_peak={i_peak} at loc {mode_guesses[i_peak]:g}, parameter error too low')
+            elif (np.abs(errs_i / pars_i) < 1e-7).any():
+                log.debug(
+                    f"hpge_fit_E_peaks: cov estimation failed for i_peak={i_peak} at loc {mode_guesses[i_peak]:g}, parameter error too low"
+                )
                 pars_i, errs_i, cov_i, p_val = None, None, None, None
 
-            elif np.abs(total_events[0] -np.sum(hist))/np.sum(hist)>0.1:
-                print(f'hpge_fit_E_peaks: fit failed for i_peak={i_peak} at loc {mode_guesses[i_peak]:g}, total_events is outside limit')
+            elif np.abs(total_events[0] - np.sum(hist)) / np.sum(hist) > 0.1:
+                log.debug(
+                    f"hpge_fit_E_peaks: fit failed for i_peak={i_peak} at loc {mode_guesses[i_peak]:g}, total_events is outside limit"
+                )
                 pars_i, errs_i, cov_i, p_val = None, None, None, None
 
-            elif p_val<allowed_p_val:
-                print(f'hpge_fit_E_peaks: fit failed for i_peak={i_peak}, p-value too low: {p_val}')
+            elif p_val < allowed_p_val:
+                log.debug(
+                    f"hpge_fit_E_peaks: fit failed for i_peak={i_peak}, p-value too low: {p_val}"
+                )
                 pars_i, errs_i, cov_i, p_val = None, None, None, None
 
-        except: pars_i, errs_i, cov_i, p_val = None, None, None, None
+        except:
+            pars_i, errs_i, cov_i, p_val = None, None, None, None
 
-        #get binning
-        binw_1 = (bins[-1]-bins[0])/(len(bins)-1)
+        # get binning
+        binw_1 = (bins[-1] - bins[0]) / (len(bins) - 1)
 
         pars.append(pars_i)
         errors.append(errs_i)
@@ -453,8 +633,14 @@ def hpge_fit_E_peaks(E_uncal, mode_guesses, wwidths, n_bins=50, funcs=pgd.nb_gau
         ranges.append([Euc_min, Euc_max])
         p_vals.append(p_val)
 
-    return (np.array(pars, dtype = object), np.array(errors, dtype = object),
-        np.array(covs, dtype = object), np.array(binws), np.array(ranges), np.array(p_vals))
+    return (
+        np.array(pars, dtype=object),
+        np.array(errors, dtype=object),
+        np.array(covs, dtype=object),
+        np.array(binws),
+        np.array(ranges),
+        np.array(p_vals),
+    )
 
 
 def hpge_fit_E_scale(mus, mu_vars, Es_keV, deg=0):
@@ -485,7 +671,7 @@ def hpge_fit_E_scale(mus, mu_vars, Es_keV, deg=0):
         pars = np.array([scale, 0])
         cov = np.array([[scale_cov, 0], [0, 0]])
     else:
-        pars, cov = np.polyfit(Es_keV, mus, deg=deg, w=1/np.sqrt(mu_vars), cov=True)
+        pars, cov = np.polyfit(Es_keV, mus, deg=deg, w=1 / np.sqrt(mu_vars), cov=True)
     return pars, cov
 
 
@@ -517,24 +703,37 @@ def hpge_fit_E_cal_func(mus, mu_vars, Es_keV, E_scale_pars, deg=0):
         covariance matrix for the best fit parameters.
     """
     if deg == 0:
-        E_vars = mu_vars/E_scale_pars[0]**2
-        scale, scale_cov = pgl.fit_simple_scaling(mus, Es_keV, var=E_vars)
+        E_vars = mu_vars / E_scale_pars[0] ** 2
+        scale, scale_cov = pgu.fit_simple_scaling(mus, Es_keV, var=E_vars)
         pars = np.array([scale, 0])
         cov = np.array([[scale_cov, 0], [0, 0]])
     else:
         mu_ns = np.ones(len(mus))
         dmudEs = np.zeros(len(mus))
-        for n in range(len(E_scale_pars)-1):
-            dmudEs += mu_ns*E_scale_pars[-n-1]
+        for n in range(len(E_scale_pars) - 1):
+            dmudEs += mu_ns * E_scale_pars[-n - 1]
             mu_ns *= mus
-        E_weights = dmudEs/np.sqrt(mu_vars)
+        E_weights = dmudEs / np.sqrt(mu_vars)
         pars, cov = np.polyfit(mus, Es_keV, deg=deg, w=E_weights, cov=True)
     return pars, cov
 
 
-def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False, range_keV=None,
-                       funcs=pgd.nb_gauss_step_cdf, gof_funcs = None, method = 'unbinned', gof_func =None,
-                       n_events=15000, simplex=False, allowed_p_val=0.05, verbose=True):
+def hpge_E_calibration(
+    E_uncal,
+    peaks_keV,
+    guess_keV,
+    deg=0,
+    uncal_is_int=False,
+    range_keV=None,
+    funcs=pgf.gauss_step_cdf,
+    gof_funcs=None,
+    method="unbinned",
+    gof_func=None,
+    n_events=15000,
+    simplex=False,
+    allowed_p_val=0.05,
+    verbose=True,
+):
     """Calibrate HPGe data to a set of known peaks
 
     Parameters
@@ -588,12 +787,12 @@ def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False,
             arrays of gaussian parameters / covariances fit to the peak tops in
             the first refinement
         'pt_cal_pars', 'pt_cal_cov' : array, 2D array
-            array of calibraiton parameters E_uncal = poly(E_keV) for fit to
+            array of calibration parameters E_uncal = poly(E_keV) for fit to
             means of gausses fit to tops of each peak
         'pk_pars', 'pk_cov', 'pk_binws', 'pk_ranges' : list of (array), list of (2D array), list, list of (array)
             the best fit parameters, covariances, bin width and energy range for the local fit to each peak
         'pk_cal_pars', 'pk_cal_cov' : array, 2D array
-            array of calibraiton parameters E_uncal = poly(E_keV) for fit to
+            array of calibration parameters E_uncal = poly(E_keV) for fit to
             means from full peak fits
         'fwhms', 'dfwhms' : array, array
             the numeric fwhms and their uncertainties for each peak.
@@ -603,55 +802,67 @@ def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False,
     if not isinstance(range_keV, list):
         range_keV = [range_keV for peak in peaks_keV]
 
-    if not hasattr(funcs, '__len__'):
+    if not hasattr(funcs, "__len__"):
         funcs = [funcs for peak in peaks_keV]
 
     # sanity checks
     E_uncal = np.asarray(E_uncal)
-    peaks_keV = np.asarray(peaks_keV) #peaks_keV = np.sort(peaks_keV)
+    peaks_keV = np.asarray(peaks_keV)  # peaks_keV = np.sort(peaks_keV)
     deg = int(deg)
     if guess_keV <= 0:
-        print(f'hpge_E_cal warning: invalid guess_keV = {guess_keV}')
+        log.error(f"hpge_E_cal warning: invalid guess_keV = {guess_keV}")
         return None, None, results
     if deg < 0:
-        print(f'hpge_E_cal warning: invalid deg = {deg}')
+        log.error(f"hpge_E_cal warning: invalid deg = {deg}")
         return None, None, results
 
     # bin the histogram in ~1 keV bins for the initial rough peak search
-    Euc_min = peaks_keV[0]/guess_keV * 0.6
-    Euc_max = peaks_keV[-1]/guess_keV * 1.1
-    dEuc = 1/guess_keV
+    Euc_min = peaks_keV[0] / guess_keV * 0.6
+    Euc_max = peaks_keV[-1] / guess_keV * 1.1
+    dEuc = 1 / guess_keV
     if uncal_is_int:
-        Euc_min, Euc_max, dEuc = pgh.better_int_binning(x_lo=Euc_min, x_hi=Euc_max, dx=dEuc)
+        Euc_min, Euc_max, dEuc = pgh.better_int_binning(
+            x_lo=Euc_min, x_hi=Euc_max, dx=dEuc
+        )
     hist, bins, var = pgh.get_hist(E_uncal, range=(Euc_min, Euc_max), dx=dEuc)
 
     # Run the initial rough peak search
-    detected_peaks_locs, detected_peaks_keV, roughpars = hpge_find_E_peaks(hist, bins, var, peaks_keV, n_sigma=5, deg=deg)
-    if verbose:
-        print(f"{len(detected_peaks_locs)} peaks found:")
-        print(f'\t   Energy   | Position  ')
-        for i, (Li, Ei) in enumerate(zip(detected_peaks_locs, detected_peaks_keV)):
-            print(f'\t{i}'.ljust(4) + str(Ei).ljust(9) + f'| {Li:g}'.ljust(5))
+    detected_peaks_locs, detected_peaks_keV, roughpars = hpge_find_E_peaks(
+        hist, bins, var, peaks_keV, n_sigma=5, deg=deg
+    )
+    log.info(f"{len(detected_peaks_locs)} peaks found:")
+    log.info(f"\t   Energy   | Position  ")
+    for i, (Li, Ei) in enumerate(zip(detected_peaks_locs, detected_peaks_keV)):
+        log.info(f"\t{i}".ljust(4) + str(Ei).ljust(9) + f"| {Li:g}".ljust(5))
 
     # re-bin the histogram in ~0.2 keV bins with updated E scale par for peak-top fits
-    Euc_min, Euc_max = ((np.poly1d(roughpars)-i).roots for i in (peaks_keV[0]*.9, peaks_keV[-1]*1.1))
+    Euc_min, Euc_max = (
+        (np.poly1d(roughpars) - i).roots
+        for i in (peaks_keV[0] * 0.9, peaks_keV[-1] * 1.1)
+    )
     Euc_min = Euc_min[np.logical_and(Euc_min >= 0, Euc_min <= max(Euc_max))][0]
-    Euc_max = Euc_max[np.logical_and(Euc_max >= Euc_min, Euc_max <= max(E_uncal))][0]
-    dEuc = 0.2/roughpars[-2]
+    Euc_max = Euc_max[
+        np.logical_and(Euc_max >= Euc_min, Euc_max <= np.nanmax(E_uncal))
+    ][0]
+    dEuc = 0.2 / roughpars[-2]
 
     if uncal_is_int:
-        Euc_min, Euc_max, dEuc = pgh.better_int_binning(x_lo=Euc_min, x_hi=Euc_max, dx=dEuc)
+        Euc_min, Euc_max, dEuc = pgh.better_int_binning(
+            x_lo=Euc_min, x_hi=Euc_max, dx=dEuc
+        )
     hist, bins, var = pgh.get_hist(E_uncal, range=(Euc_min, Euc_max), dx=dEuc)
 
-    #run peak getter after rebinning
-    got_peaks_locs, got_peaks_keV, roughpars = hpge_get_E_peaks(hist, bins, var, roughpars, peaks_keV, n_sigma=3)
-    results['got_peaks_locs'] = got_peaks_locs
-    results['got_peaks_keV'] = got_peaks_keV
-    if verbose:
-        print(f"{len(got_peaks_locs)} peaks obtained:")
-        print(f'\t   Energy   | Position  ')
-        for i, (Li, Ei) in enumerate(zip(got_peaks_locs, got_peaks_keV)):
-            print(f'\t{i}'.ljust(4) + str(Ei).ljust(9) + f'| {Li:g}'.ljust(5))
+    # run peak getter after rebinning
+    got_peaks_locs, got_peaks_keV, roughpars = hpge_get_E_peaks(
+        hist, bins, var, roughpars, peaks_keV, n_sigma=3
+    )
+    results["got_peaks_locs"] = got_peaks_locs
+    results["got_peaks_keV"] = got_peaks_keV
+
+    log.info(f"{len(got_peaks_locs)} peaks obtained:")
+    log.info(f"\t   Energy   | Position  ")
+    for i, (Li, Ei) in enumerate(zip(got_peaks_locs, got_peaks_keV)):
+        log.info(f"\t{i}".ljust(4) + str(Ei).ljust(9) + f"| {Li:g}".ljust(5))
 
     # Drop non-gotten peaks
     idx = [i for i, E in enumerate(peaks_keV) if E in got_peaks_keV]
@@ -659,7 +870,13 @@ def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False,
     funcs = [funcs[i] for i in idx]
 
     # Drop peaks to not be fitted
-    tmp = zip(*[a for a in zip(got_peaks_locs, got_peaks_keV, range_keV, funcs) if a[2] and a[3]])
+    tmp = zip(
+        *[
+            a
+            for a in zip(got_peaks_locs, got_peaks_keV, range_keV, funcs)
+            if a[2] and a[3]
+        ]
+    )
     got_peaks_locs, got_peaks_keV, range_keV, funcs = list(map(list, tmp))
     got_peaks_locs = np.asarray(got_peaks_locs)
     got_peaks_keV = np.asarray(got_peaks_keV)
@@ -668,87 +885,113 @@ def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False,
 
     # First calculate range around peaks to fit
     if range_keV is None:
-        #Need to do initial fit
-        pt_pars, pt_covs = hpge_fit_E_peak_tops(hist, bins, var, got_peaks_locs, n_to_fit=7)
+        # Need to do initial fit
+        pt_pars, pt_covs = hpge_fit_E_peak_tops(
+            hist, bins, var, got_peaks_locs, n_to_fit=7
+        )
         # Drop failed fits
         fitidx = [i is not None for i in pt_pars]
-        results['got_peaks_locs'] = got_peaks_locs = got_peaks_locs[fitidx]
-        results['got_peaks_keV'] = got_peaks_keV = got_peaks_keV[fitidx]
+        results["got_peaks_locs"] = got_peaks_locs = got_peaks_locs[fitidx]
+        results["got_peaks_keV"] = got_peaks_keV = got_peaks_keV[fitidx]
         pt_pars = np.asarray(pt_pars)[fitidx]
         pt_covs = np.asarray(pt_covs)[fitidx]
-        range_uncal = np.stack(pt_pars)[:,1].astype(float)*20
+        range_uncal = np.stack(pt_pars)[:, 1].astype(float) * 20
         n_bins = 50
     elif np.isscalar(range_keV):
         derco = np.polyder(np.poly1d(roughpars)).coefficients
         der = [pgd.nb_poly(Ei, derco) for Ei in got_peaks_keV]
         range_uncal = [float(range_keV) / d for d in der]
-        n_bins = [range_keV/0.5 /d for d in der]
+        n_bins = [range_keV / 0.5 / d for d in der]
     elif isinstance(range_keV, tuple):
         rangeleft_keV, rangeright_keV = range_keV
         derco = np.polyder(np.poly1d(roughpars)).coefficients
-        der = [pgd.nb_poly(Ei, derco) for Ei in got_peaks_keV]
-        range_uncal = [(rangeleft_keV/d, rangeright_keV/d) for d in der]
-        n_bins = [sum(range_keV)/0.5 /d for d in der]
+        der = [pgf.poly(Ei, derco) for Ei in got_peaks_keV]
+        range_uncal = [(rangeleft_keV / d, rangeright_keV / d) for d in der]
+        n_bins = [sum(range_keV) / 0.5 / d for d in der]
     elif isinstance(range_keV, list):
         derco = np.polyder(np.poly1d(roughpars)).coefficients
-        der = [pgd.nb_poly(Ei, derco) for Ei in got_peaks_keV]
-        range_uncal = [(r[0]/d, r[1]/d) if isinstance(r, tuple) else r/d for r, d in zip(range_keV, der)]
-        n_bins = [sum(r)/0.5/d if isinstance(r, tuple) else r/0.2/d for r, d in zip(range_keV, der)]
-
-    pk_pars,pk_errors, pk_covs, pk_binws, pk_ranges, pk_pvals = hpge_fit_E_peaks(E_uncal, got_peaks_locs, range_uncal,
-                                    n_bins=n_bins,
-                                    funcs=funcs, method=method, gof_funcs = gof_funcs, n_events=n_events,
-                                    uncal_is_int=False, simplex=simplex, allowed_p_val=allowed_p_val)
-    results['pk_pars'] = pk_pars
-    results['pk_errors'] = pk_errors
-    results['pk_covs'] = pk_covs
-    results['pk_binws'] = pk_binws
-    results['pk_ranges'] = pk_ranges
-    results['pk_pvals'] = pk_pvals
+        der = [pgf.poly(Ei, derco) for Ei in got_peaks_keV]
+        range_uncal = [
+            (r[0] / d, r[1] / d) if isinstance(r, tuple) else r / d
+            for r, d in zip(range_keV, der)
+        ]
+        n_bins = [
+            sum(r) / 0.5 / d if isinstance(r, tuple) else r / 0.2 / d
+            for r, d in zip(range_keV, der)
+        ]
+
+    pk_pars, pk_errors, pk_covs, pk_binws, pk_ranges, pk_pvals = hpge_fit_E_peaks(
+        E_uncal,
+        got_peaks_locs,
+        range_uncal,
+        n_bins=n_bins,
+        funcs=funcs,
+        method=method,
+        gof_funcs=gof_funcs,
+        n_events=n_events,
+        uncal_is_int=False,
+        simplex=simplex,
+        allowed_p_val=allowed_p_val,
+    )
+    results["pk_pars"] = pk_pars
+    results["pk_errors"] = pk_errors
+    results["pk_covs"] = pk_covs
+    results["pk_binws"] = pk_binws
+    results["pk_ranges"] = pk_ranges
+    results["pk_pvals"] = pk_pvals
     # Drop failed fits
     fitidx = [i is not None for i in pk_pars]
-    fitted_peaks_keV = results['fitted_keV'] = got_peaks_keV[fitidx]
+    fitted_peaks_keV = results["fitted_keV"] = got_peaks_keV[fitidx]
     funcs = [f for i, f in zip(fitidx, funcs) if i]
-    pk_pars = results['pk_pars'] = np.asarray(pk_pars, dtype=object)[fitidx] #ragged
-    pk_errors = results['pk_errors'] = np.asarray(pk_errors, dtype=object)[fitidx]
-    pk_covs = results['pk_covs'] = np.asarray(pk_covs, dtype=object)[fitidx]
-    pk_binws = results['pk_binws'] = np.asarray(pk_binws)[fitidx]
-    pk_ranges = results['pk_ranges'] = np.asarray(pk_ranges)[fitidx]
-    pk_pvals = results['pk_pvals'] = np.asarray(pk_pvals)[fitidx]
-    if verbose:
-        print(f"{sum(fitidx)} peaks fitted:")
-        for i, (Ei, parsi, errorsi, covsi) in enumerate(zip(fitted_peaks_keV, pk_pars, pk_errors, pk_covs)):
-            func_i = funcs[i] if hasattr(funcs, '__len__') else funcs
-            varnames = func_i.__code__.co_varnames[1:len(pk_pars[-1])+1]
-            parsi = np.asarray(parsi, dtype=float)
-            errorsi = np.asarray(errorsi, dtype=float)
-            covsi = np.asarray(covsi, dtype=float)
-            #parsigsi = np.sqrt(covsi.diagonal())
-            print(f'\tEnergy: {str(Ei)}')
-            print(f'\t\tParameter  |    Value +/- Sigma  ')
-            for (vari, pari, errorsi) in zip(varnames, parsi, errorsi):
-                print(f'\t\t{str(vari).ljust(10)} | {("%4.2f" % pari).rjust(8)} +/- {("%4.2f" % errorsi).ljust(8)}')
-                #fwhm??
+    pk_pars = results["pk_pars"] = np.asarray(pk_pars, dtype=object)[fitidx]  # ragged
+    pk_errors = results["pk_errors"] = np.asarray(pk_errors, dtype=object)[fitidx]
+    pk_covs = results["pk_covs"] = np.asarray(pk_covs, dtype=object)[fitidx]
+    pk_binws = results["pk_binws"] = np.asarray(pk_binws)[fitidx]
+    pk_ranges = results["pk_ranges"] = np.asarray(pk_ranges)[fitidx]
+    pk_pvals = results["pk_pvals"] = np.asarray(pk_pvals)[fitidx]
+    log.info(f"{sum(fitidx)} peaks fitted:")
+    for i, (Ei, parsi, errorsi, covsi) in enumerate(
+        zip(fitted_peaks_keV, pk_pars, pk_errors, pk_covs)
+    ):
+        func_i = funcs[i] if hasattr(funcs, "__len__") else funcs
+        varnames = func_i.__code__.co_varnames[1 : len(pk_pars[-1]) + 1]
+        parsi = np.asarray(parsi, dtype=float)
+        errorsi = np.asarray(errorsi, dtype=float)
+        covsi = np.asarray(covsi, dtype=float)
+        # parsigsi = np.sqrt(covsi.diagonal())
+        log.info(f"\tEnergy: {str(Ei)}")
+        log.info(f"\t\tParameter  |    Value +/- Sigma  ")
+        for (vari, pari, errorsi) in zip(varnames, parsi, errorsi):
+            log.info(
+                f'\t\t{str(vari).ljust(10)} | {("%4.2f" % pari).rjust(8)} +/- {("%4.2f" % errorsi).ljust(8)}'
+            )
+            # fwhm??
 
     # Do a second calibration to the results of the full peak fits
-    mus = [pgds.get_mu_func(func_i, pars_i, errors=errors_i) for func_i, pars_i, errors_i in zip(funcs, pk_pars, pk_errors)]
+    mus = [
+        pgf.get_mu_func(func_i, pars_i, errors=errors_i)
+        for func_i, pars_i, errors_i in zip(funcs, pk_pars, pk_errors)
+    ]
     mus, mu_vars = zip(*mus)
     mus = np.asarray(mus)
-    mu_vars = np.asarray(mu_vars)**2
+    mu_vars = np.asarray(mu_vars) ** 2
 
     try:
         pars, cov = hpge_fit_E_scale(mus, mu_vars, fitted_peaks_keV, deg=deg)
-        results['pk_cal_pars'] = pars
-        results['pk_cal_cov'] = cov
+        results["pk_cal_pars"] = pars
+        results["pk_cal_cov"] = cov
     except ValueError:
-        print("Failed to fit enough peaks to get accurate calibration")
+        log.error("Failed to fit enough peaks to get accurate calibration")
         return None, None, results
 
     # Invert the E scale fit to get a calibration function
     pars, cov = hpge_fit_E_cal_func(mus, mu_vars, fitted_peaks_keV, pars, deg=deg)
 
     # Finally, calculate fwhms in keV
-    uncal_fwhms = [pgds.get_fwhm_func(func_i, pars_i, cov = covs_i) for func_i, pars_i, covs_i in zip(funcs, pk_pars, pk_covs)]
+    uncal_fwhms = [
+        pgf.get_fwhm_func(func_i, pars_i, cov=covs_i)
+        for func_i, pars_i, covs_i in zip(funcs, pk_pars, pk_covs)
+    ]
     uncal_fwhms, uncal_fwhm_errs = zip(*uncal_fwhms)
     uncal_fwhms = np.asarray(uncal_fwhms)
     uncal_fwhm_errs = np.asarray(uncal_fwhm_errs)
@@ -756,17 +999,23 @@ def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False,
     der = [pgd.nb_poly(Ei, derco) for Ei in fitted_peaks_keV]
 
     cal_fwhms = uncal_fwhms * der
-    cal_fwhms_errs = uncal_fwhm_errs*der
-    results['pk_fwhms'] = np.asarray([(u*d, e*d) for u, e, d in zip(uncal_fwhms, uncal_fwhm_errs, der)])
-
-    if verbose:
-        print(f"{len(cal_fwhms)} FWHMs found:")
-        print(f'\t   Energy   | FWHM  ')
-        for i, (Ei, fwhm, fwhme) in enumerate(zip(fitted_peaks_keV, cal_fwhms, cal_fwhms_errs)):
-            print(f'\t{i}'.ljust(4) + str(Ei).ljust(9) + f'| {fwhm:.2f}+-{fwhme:.2f} keV'.ljust(5))
-
-    return   pars, cov, results
+    cal_fwhms_errs = uncal_fwhm_errs * der
+    results["pk_fwhms"] = np.asarray(
+        [(u * d, e * d) for u, e, d in zip(uncal_fwhms, uncal_fwhm_errs, der)]
+    )
+
+    log.info(f"{len(cal_fwhms)} FWHMs found:")
+    log.info(f"\t   Energy   | FWHM  ")
+    for i, (Ei, fwhm, fwhme) in enumerate(
+        zip(fitted_peaks_keV, cal_fwhms, cal_fwhms_errs)
+    ):
+        log.info(
+            f"\t{i}".ljust(4)
+            + str(Ei).ljust(9)
+            + f"| {fwhm:.2f}+-{fwhme:.2f} keV".ljust(5)
+        )
 
+    return pars, cov, results
 
 
 def poly_match(xx, yy, deg=-1, rtol=1e-5, atol=1e-8):
@@ -810,16 +1059,18 @@ def poly_match(xx, yy, deg=-1, rtol=1e-5, atol=1e-8):
     # input handling
     xx = np.asarray(xx)
     yy = np.asarray(yy)
-#    if len(xx) <= len(yy):
-#        print(f"poly_match error: len(xx)={len(xx)} <= len(yy)={len(yy)}")
-#        return None, 0
+    #    if len(xx) <= len(yy):
+    #        print(f"poly_match error: len(xx)={len(xx)} <= len(yy)={len(yy)}")
+    #        return None, 0
     deg = int(deg)
     if deg < -1:
-        print(f"poly_match error: got bad deg = {deg}")
+        log.error(f"poly_match error: got bad deg = {deg}")
         return None, 0
-    req_ylen = max(2, deg+2)
+    req_ylen = max(2, deg + 2)
     if len(yy) < req_ylen:
-        print(f"poly_match error: len(yy) must be at least {req_ylen} for deg={deg}, got {len(yy)}")
+        log.error(
+            f"poly_match error: len(yy) must be at least {req_ylen} for deg={deg}, got {len(yy)}"
+        )
         return None, 0
 
     maxoverlap = min(len(xx), len(yy))
@@ -830,7 +1081,7 @@ def poly_match(xx, yy, deg=-1, rtol=1e-5, atol=1e-8):
     best_ixtup = None
     best_iytup = None
     n_close = 0
-    gof = np.inf # lower is better gof
+    gof = np.inf  # lower is better gof
     while True:
         xx_i = xx[ixtup]
         yy_i = yy[iytup]
@@ -841,13 +1092,11 @@ def poly_match(xx, yy, deg=-1, rtol=1e-5, atol=1e-8):
             pars_i = np.array([1, (np.sum(yy_i) - np.sum(xx_i)) / len(yy_i)])
             polxx = xx_i + pars_i[1]
 
-
         # simple scaling
         elif deg == 0:
-            pars_i = np.array([np.sum(yy_i*xx_i) / np.sum(xx_i*xx_i), 0])
+            pars_i = np.array([np.sum(yy_i * xx_i) / np.sum(xx_i * xx_i), 0])
             polxx = pars_i[0] * xx_i
 
-
         # generic poly of degree >= 1
         else:
             pars_i = np.polyfit(xx_i, yy_i, deg)
@@ -871,12 +1120,13 @@ def poly_match(xx, yy, deg=-1, rtol=1e-5, atol=1e-8):
         # increment ixtup
         # first find the index of ixtup that needs to be incremented
         ii = 0
-        while ii < len(ixtup)-1:
-            if ixtup[ii] < ixtup[ii+1]-1: break
+        while ii < len(ixtup) - 1:
+            if ixtup[ii] < ixtup[ii + 1] - 1:
+                break
             ii += 1
 
         # quit if ii is the last index of ixtup and it's already maxed out
-        if not( ii == len(ixtup) - 1 and ixtup[ii] == len(xx)-1 ):
+        if not (ii == len(ixtup) - 1 and ixtup[ii] == len(xx) - 1):
 
             # otherwise increment ii and reset indices < ii
             ixtup[ii] += 1
@@ -886,23 +1136,24 @@ def poly_match(xx, yy, deg=-1, rtol=1e-5, atol=1e-8):
         # increment iytup
         # first find the index of iytup that needs to be incremented
         ii = 0
-        while ii < len(iytup)-1:
-            if iytup[ii] < iytup[ii+1]-1: break
+        while ii < len(iytup) - 1:
+            if iytup[ii] < iytup[ii + 1] - 1:
+                break
             ii += 1
 
         # quit if ii is the last index of iytup and it's already maxed out
-        if not( ii == len(iytup) - 1 and iytup[ii] == len(yy)-1 ):
+        if not (ii == len(iytup) - 1 and iytup[ii] == len(yy) - 1):
 
             # otherwise increment ii and reset indices < ii
             iytup[ii] += 1
             iytup[0:ii] = list(range(ii))
-            ixtup = np.array(list(range(len(iytup)))) #(reset ix)
+            ixtup = np.array(list(range(len(iytup))))  # (reset ix)
             continue
 
-        if n_close == len(iytup): #found best
+        if n_close == len(iytup):  # found best
             break
 
-        #reduce overlap
+        # reduce overlap
         new_len = len(iytup) - 1
         if new_len < req_ylen:
             break
@@ -946,10 +1197,10 @@ def get_i_local_extrema(data, delta):
     # sanity checks
     data = np.asarray(data)
     if not np.isscalar(delta):
-        print("get_i_local_extrema: Input argument delta must be a scalar")
+        log.error("get_i_local_extrema: Input argument delta must be a scalar")
         return np.array(imaxes), np.array(imins)
     if delta <= 0:
-        print(f"get_i_local_extrema: delta ({delta}) must be positive")
+        log.error(f"get_i_local_extrema: delta ({delta}) must be positive")
         return np.array(imaxes), np.array(imins)
 
     # now loop over data
@@ -957,8 +1208,10 @@ def get_i_local_extrema(data, delta):
     find_max = True
     for i in range(len(data)):
 
-        if data[i] > data[imax]: imax = i
-        if data[i] < data[imin]: imin = i
+        if data[i] > data[imax]:
+            imax = i
+        if data[i] < data[imin]:
+            imin = i
 
         if find_max:
             # if the sample is less than the current max by more than delta,
@@ -977,20 +1230,25 @@ def get_i_local_extrema(data, delta):
 
     return np.array(imaxes), np.array(imins)
 
-def get_i_local_maxima(data, delta): return get_i_local_extrema(data, delta)[0]
 
-def get_i_local_minima(data, delta): return get_i_local_extrema(data, delta)[1]
+def get_i_local_maxima(data, delta):
+    return get_i_local_extrema(data, delta)[0]
+
 
+def get_i_local_minima(data, delta):
+    return get_i_local_extrema(data, delta)[1]
 
-def get_most_prominent_peaks(energySeries, xlo, xhi, xpb,
-                             max_num_peaks=np.inf, test=False):
+
+def get_most_prominent_peaks(
+    energySeries, xlo, xhi, xpb, max_num_peaks=np.inf, test=False
+):
     """find the most prominent peaks in a spectrum by looking for spikes in
     derivative of spectrum energySeries: array of measured energies
     max_num_peaks = maximum number of most prominent peaks to find return a
     histogram around the most prominent peak in a spectrum of a given
     percentage of width
     """
-    nb = int((xhi-xlo)/xpb)
+    nb = int((xhi - xlo) / xpb)
     hist, bin_edges = np.histogram(energySeries, range=(xlo, xhi), bins=nb)
     bin_centers = pgh.get_bin_centers(bin_edges)
 
@@ -1010,11 +1268,11 @@ def get_most_prominent_peaks(energySeries, xlo, xhi, xpb,
         peak_energies = np.sort(bin_centers[peak_idxs_max])
 
     if test:
-        plt.plot(bin_centers, hist, ls='steps', lw=1, c='b')
+        plt.plot(bin_centers, hist, ls="steps", lw=1, c="b")
         for e in peak_energies:
             plt.axvline(e, color="r", lw=1, alpha=0.6)
-        plt.xlabel("Energy [uncal]", ha='right', x=1)
-        plt.ylabel("Filtered Spectrum", ha='right', y=1)
+        plt.xlabel("Energy [uncal]", ha="right", x=1)
+        plt.ylabel("Filtered Spectrum", ha="right", y=1)
         plt.tight_layout()
         plt.show()
         exit()
@@ -1036,29 +1294,30 @@ def match_peaks(data_pks, cal_pks):
     data_sets = combinations(range(len(data_pks)), n_pks)
 
     best_err, best_m, best_b = np.inf, None, None
-    for i,cal_set in enumerate(cal_sets):
+    for i, cal_set in enumerate(cal_sets):
 
-        cal = cal_pks[list(cal_set)] # lit energies for this set
+        cal = cal_pks[list(cal_set)]  # lit energies for this set
 
         for data_set in data_sets:
 
-            data = data_pks[list(data_set)] # uncal energies for this set
+            data = data_pks[list(data_set)]  # uncal energies for this set
 
             m, b, _, _, _ = linregress(data, y=cal)
-            err = np.sum((cal - (m * data + b))**2)
+            err = np.sum((cal - (m * data + b)) ** 2)
 
             if err < best_err:
                 best_err, best_m, best_b = err, m, b
 
     print(i, best_err)
-    print("cal:",cal)
-    print("data:",data)
-    plt.scatter(data, cal, label=f'min.err:{err:.2e}')
+    print("cal:", cal)
+    print("data:", data)
+    plt.scatter(data, cal, label=f"min.err:{err:.2e}")
     xs = np.linspace(data[0], data[-1], 10)
-    plt.plot(xs, best_m * xs + best_b , c="r",
-             label=f"y = {best_m:.2f} x + {best_b:.2f}" )
-    plt.xlabel("Energy (uncal)", ha='right', x=1)
-    plt.ylabel("Energy (keV)", ha='right', y=1)
+    plt.plot(
+        xs, best_m * xs + best_b, c="r", label=f"y = {best_m:.2f} x + {best_b:.2f}"
+    )
+    plt.xlabel("Energy (uncal)", ha="right", x=1)
+    plt.ylabel("Energy (keV)", ha="right", y=1)
     plt.legend()
     plt.tight_layout()
     plt.show()
@@ -1079,19 +1338,21 @@ def calibrate_tl208(energy_series, cal_peaks=None, plotFigure=None):
 
     if cal_peaks is None:
         cal_peaks = np.array(
-            [238.632, 510.770, 583.191, 727.330, 860.564,
-             2614.553])  #get_calibration_energies(peak_energies)
+            [238.632, 510.770, 583.191, 727.330, 860.564, 2614.553]
+        )  # get_calibration_energies(peak_energies)
     else:
         cal_peaks = np.array(cal_peaks)
 
     if len(energy_series) < 100:
         return 1, 0
 
-    #get 10 most prominent ~high e peaks
+    # get 10 most prominent ~high e peaks
     max_adc = np.amax(energy_series)
-    energy_hi = energy_series  #[ (energy_series > np.percentile(energy_series, 20)) & (energy_series < np.percentile(energy_series, 99.9))]
+    energy_hi = energy_series  # [ (energy_series > np.percentile(energy_series, 20)) & (energy_series < np.percentile(energy_series, 99.9))]
 
-    peak_energies, peak_e_err = get_most_prominent_peaks(energy_hi,)
+    peak_energies, peak_e_err = get_most_prominent_peaks(
+        energy_hi,
+    )
     rough_kev_per_adc, rough_kev_offset = match_peaks(peak_energies, cal_peaks)
     e_cal_rough = rough_kev_per_adc * energy_series + rough_kev_offset
 
@@ -1111,33 +1372,37 @@ def calibrate_tl208(energy_series, cal_peaks=None, plotFigure=None):
     # exit()
 
     ###############################################
-    #Do a real fit to every peak in peak_energies
+    # Do a real fit to every peak in peak_energies
     ###############################################
     max_adc = np.amax(energy_series)
 
     peak_num = len(cal_peaks)
     centers = np.zeros(peak_num)
     fit_result_map = {}
-    bin_size = 0.2  #keV
+    bin_size = 0.2  # keV
 
     if plotFigure is not None:
         plot_map = {}
 
     for i, energy in enumerate(cal_peaks):
-        window_width = 10  #keV
+        window_width = 10  # keV
         window_width_in_adc = (window_width) / rough_kev_per_adc
         energy_in_adc = (energy - rough_kev_offset) / rough_kev_per_adc
         bin_size_adc = (bin_size) / rough_kev_per_adc
 
         peak_vals = energy_series[
-            (energy_series > energy_in_adc - window_width_in_adc) &
-            (energy_series < energy_in_adc + window_width_in_adc)]
+            (energy_series > energy_in_adc - window_width_in_adc)
+            & (energy_series < energy_in_adc + window_width_in_adc)
+        ]
 
         peak_hist, bins = np.histogram(
             peak_vals,
-            bins=np.arange(energy_in_adc - window_width_in_adc,
-                           energy_in_adc + window_width_in_adc + bin_size_adc,
-                           bin_size_adc))
+            bins=np.arange(
+                energy_in_adc - window_width_in_adc,
+                energy_in_adc + window_width_in_adc + bin_size_adc,
+                bin_size_adc,
+            ),
+        )
         bin_centers = pgh.get_bin_centers(bins)
         # plt.ion()
         # plt.figure()
@@ -1147,11 +1412,11 @@ def calibrate_tl208(energy_series, cal_peaks=None, plotFigure=None):
         # if inp == "q": exit()
 
         try:
-            guess_e, guess_sigma, guess_area = pgh.get_gaussian_guess(
-                peak_hist, bin_centers)
+            guess_e, guess_sigma, guess_area = get_gaussian_guess(
+                peak_hist, bin_centers
+            )
         except IndexError:
-            print("\n\nIt looks like there may not be a peak at {} keV".format(
-                energy))
+            print(f"\n\nIt looks like there may not be a peak at {energy} keV")
             print("Here is a plot of the area I'm searching for a peak...")
             plt.ion()
             plt.figure(figsize=(12, 6))
@@ -1163,23 +1428,30 @@ def calibrate_tl208(energy_series, cal_peaks=None, plotFigure=None):
             sys.exit()
 
         plt.plot(
-            bin_centers,
-            gauss(bin_centers, guess_e, guess_sigma, guess_area),
-            color="b")
+            bin_centers, gauss(bin_centers, guess_e, guess_sigma, guess_area), color="b"
+        )
 
         # inp = input("q to quit...")
         # if inp == "q": exit()
 
-        bounds = ([0.9 * guess_e, 0.5 * guess_sigma, 0, 0, 0, 0, 0], [
-            1.1 * guess_e, 2 * guess_sigma, 0.1, 0.75, window_width_in_adc, 10,
-            5 * guess_area
-        ])
+        bounds = (
+            [0.9 * guess_e, 0.5 * guess_sigma, 0, 0, 0, 0, 0],
+            [
+                1.1 * guess_e,
+                2 * guess_sigma,
+                0.1,
+                0.75,
+                window_width_in_adc,
+                10,
+                5 * guess_area,
+            ],
+        )
         params = fit_binned(
             radford_peak,
             peak_hist,
             bin_centers,
-            [guess_e, guess_sigma, 1E-3, 0.7, 5, 0, guess_area],
-        )  #bounds=bounds)
+            [guess_e, guess_sigma, 1e-3, 0.7, 5, 0, guess_area],
+        )  # bounds=bounds)
 
         plt.plot(bin_centers, radford_peak(bin_centers, *params), color="r")
 
@@ -1192,7 +1464,7 @@ def calibrate_tl208(energy_series, cal_peaks=None, plotFigure=None):
         if plotFigure is not None:
             plot_map[energy] = (bin_centers, peak_hist)
 
-    #Do a linear fit to find the calibration
+    # Do a linear fit to find the calibration
     linear_cal = np.polyfit(centers, cal_peaks, deg=1)
 
     if plotFigure is not None:
@@ -1212,12 +1484,12 @@ def calibrate_tl208(energy_series, cal_peaks=None, plotFigure=None):
                 bin_centers * rough_kev_per_adc + rough_kev_offset,
                 peak_hist,
                 ls="steps-mid",
-                color="k")
+                color="k",
+            )
             fit = radford_peak(bin_centers, *params)
             ax_peak.plot(
-                bin_centers * rough_kev_per_adc + rough_kev_offset,
-                fit,
-                color="b")
+                bin_centers * rough_kev_per_adc + rough_kev_offset, fit, color="b"
+            )
 
         ax_peak.set_xlabel("Energy [keV]")
 
@@ -1241,15 +1513,28 @@ def calibrate_tl208(energy_series, cal_peaks=None, plotFigure=None):
 
 def get_calibration_energies(cal_type):
     if cal_type == "th228":
-        return np.array([238, 277, 300, 452, 510.77, 583.191,
-                         727, 763, 785, 860.564, 1620, 2614.533],
-                        dtype="double")
+        return np.array(
+            [
+                238,
+                277,
+                300,
+                452,
+                510.77,
+                583.191,
+                727,
+                763,
+                785,
+                860.564,
+                1620,
+                2614.533,
+            ],
+            dtype="double",
+        )
 
     elif cal_type == "uwmjlab":
         # return np.array([239, 295, 351, 510, 583, 609, 911, 969, 1120,
         #                  1258, 1378, 1401, 1460, 1588, 1764, 2204, 2615],
         #                 dtype="double")
-        return np.array([239, 911, 1460, 1764, 2615],
-                        dtype="double")
+        return np.array([239, 911, 1460, 1764, 2615], dtype="double")
     else:
         raise ValueError
diff --git a/src/pygama/pargen/energy_optimisation.py b/src/pygama/pargen/energy_optimisation.py
new file mode 100644
index 000000000..c59d0453e
--- /dev/null
+++ b/src/pygama/pargen/energy_optimisation.py
@@ -0,0 +1,1870 @@
+"""
+This module contains the functions for performing the energy optimisation.
+This happens in 2 steps, firstly a grid search is performed on each peak
+separately using the optimiser, then the resulting grids are interpolated
+to provide the best energy resolution at Qbb
+"""
+
+import json
+import logging
+import os
+import pathlib
+import pickle as pkl
+import sys
+from collections import namedtuple
+
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+import pandas as pd
+from iminuit import Minuit, cost, util
+from matplotlib.backends.backend_pdf import PdfPages
+from matplotlib.colors import LogNorm
+from scipy.optimize import curve_fit, minimize
+from scipy.stats import chisquare, norm
+from sklearn.gaussian_process import GaussianProcessRegressor
+from sklearn.gaussian_process.kernels import RBF, ConstantKernel
+
+import pygama.lgdo.lh5_store as lh5
+import pygama.math.histogram as pgh
+import pygama.math.peak_fitting as pgf
+import pygama.pargen.cuts as cts
+import pygama.pargen.dsp_optimize as opt
+import pygama.pargen.energy_cal as pgc
+
+log = logging.getLogger(__name__)
+sto = lh5.LH5Store()
+
+
+def run_optimisation(
+    file, opt_config, dsp_config, cuts, fom, db_dict=None, n_events=8000, **fom_kwargs
+):
+    """
+    Runs optimisation on .lh5 file
+
+    Parameters
+    ----------
+    file: string
+        path to raw .lh5 file
+    opt_config: str
+        path to JSON dictionary to configure optimisation
+    dsp_config: str
+        path to JSON dictionary specifying dsp configuration
+    fom: function
+        When given the output lh5 table of a DSP iteration, the
+        fom_function must return a scalar figure-of-merit value upon which the
+        optimization will be based. Should accept verbosity as a second argument
+    db_dict: dict
+        Dictionary specifying any values to put in processing chain e.g. pz constant
+    n_events : int
+        Number of events to run over
+    """
+    grid = set_par_space(opt_config)
+    waveforms = sto.read_object("/raw/waveform", file, idx=cuts, n_rows=n_events)[0]
+    baseline = sto.read_object("/raw/baseline", file, idx=cuts, n_rows=n_events)[0]
+    tb_data = lh5.Table(col_dict={"waveform": waveforms, "baseline": baseline})
+    return opt.run_grid(tb_data, dsp_config, grid, fom, db_dict, **fom_kwargs)
+
+
+def run_optimisation_multiprocessed(
+    file,
+    opt_config,
+    dsp_config,
+    cuts,
+    lh5_path,
+    fom=None,
+    db_dict=None,
+    processes=5,
+    n_events=8000,
+    **fom_kwargs,
+):
+    """
+    Runs optimisation on .lh5 file, this version multiprocesses the grid points, it also can handle multiple grids being passed
+    as long as they are the same dimensions.
+
+    Parameters
+    ----------
+    file: string
+        path to raw .lh5 file
+    opt_config: str
+        path to JSON dictionary to configure optimisation
+    dsp_config: str
+        path to JSON dictionary specifying dsp configuration
+    fom: function
+        When given the output lh5 table of a DSP iteration, the
+        fom_function must return a scalar figure-of-merit value upon which the
+        optimization will be based. Should accept verbosity as a second argument
+    n_events : int
+        Number of events to run over
+    db_dict: dict
+        Dictionary specifying any values to put in processing chain e.g. pz constant
+    processes : int
+        Number of separate processes to run for the multiprocessing
+    """
+
+    def form_dict(in_dict, length):
+        keys = list(in_dict.keys())
+        out_list = []
+        for i in range(length):
+            out_list.append({keys[0]: 0})
+        for key in keys:
+            if isinstance(in_dict[key], list):
+                if len(in_dict[key]) == length:
+                    for i in range(length):
+                        out_list[i][key] = in_dict[key][i]
+                else:
+                    for i in range(length):
+                        out_list[i][key] = in_dict[key]
+            else:
+                for i in range(length):
+                    out_list[i][key] = in_dict[key]
+        return out_list
+
+    if not isinstance(opt_config, list):
+        opt_config = [opt_config]
+    grid = []
+    for i, opt_conf in enumerate(opt_config):
+        grid.append(set_par_space(opt_conf))
+    if fom_kwargs:
+        if "fom_kwargs" in fom_kwargs:
+            fom_kwargs = fom_kwargs["fom_kwargs"]
+        fom_kwargs = form_dict(fom_kwargs, len(grid))
+    sto = lh5.LH5Store()
+    waveforms = sto.read_object(
+        f"{lh5_path}/waveform", file, idx=cuts, n_rows=n_events
+    )[0]
+    baseline = sto.read_object(f"{lh5_path}/baseline", file, idx=cuts, n_rows=n_events)[
+        0
+    ]
+    tb_data = lh5.Table(col_dict={"waveform": waveforms, "baseline": baseline})
+    return opt.run_grid_multiprocess_parallel(
+        tb_data,
+        dsp_config,
+        grid,
+        fom,
+        db_dict=db_dict,
+        processes=processes,
+        fom_kwargs=fom_kwargs,
+    )
+
+
+def set_par_space(opt_config):
+    """
+    Generates grid for optimizer from dictionary of form {param : {start: , end: , spacing: }}
+    """
+    par_space = opt.ParGrid()
+    for name in opt_config.keys():
+        p_values = opt_config[name]
+        for param in p_values.keys():
+            str_vals = set_values(p_values[param])
+            par_space.add_dimension(name, param, str_vals)
+    return par_space
+
+
+def set_values(par_values):
+    """
+    Finds values for grid
+    """
+    string_values = np.arange(
+        par_values["start"], par_values["end"], par_values["spacing"]
+    )
+    try:
+        string_values = [f'{val:.4f}*{par_values["unit"]}' for val in string_values]
+    except:
+        string_values = [f"{val:.4f}" for val in string_values]
+    return string_values
+
+
+def simple_guess(hist, bins, var, func_i, fit_range):
+    """
+    Simple guess for peak fitting
+    """
+    if func_i == pgf.extended_radford_pdf:
+        bin_cs = (bins[1:] + bins[:-1]) / 2
+        _, sigma, amp = pgh.get_gaussian_guess(hist, bins)
+        i_0 = np.nanargmax(hist)
+        mu = bin_cs[i_0]
+        height = hist[i_0]
+        bg0 = np.mean(hist[-10:])
+        step = np.mean(hist[:10]) - bg0
+        htail = 1.0 / 5
+        tau = 0.5 * sigma
+
+        hstep = step / (bg0 + np.mean(hist[:10]))
+        dx = np.diff(bins)[0]
+        n_bins_range = int((4 * sigma) // dx)
+        nsig_guess = np.sum(hist[i_0 - n_bins_range : i_0 + n_bins_range])
+        nbkg_guess = np.sum(hist) - nsig_guess
+        parguess = [
+            nsig_guess,
+            mu,
+            sigma,
+            htail,
+            tau,
+            nbkg_guess,
+            hstep,
+            fit_range[0],
+            fit_range[1],
+            0,
+        ]  #
+        return parguess
+
+    elif func_i == pgf.extended_gauss_step_pdf:
+        mu, sigma, amp = pgh.get_gaussian_guess(hist, bins)
+        i_0 = np.argmax(hist)
+        bg = np.mean(hist[-10:])
+        step = bg - np.mean(hist[:10])
+        hstep = step / (bg + np.mean(hist[:10]))
+        dx = np.diff(bins)[0]
+        n_bins_range = int((4 * sigma) // dx)
+        nsig_guess = np.sum(hist[i_0 - n_bins_range : i_0 + n_bins_range])
+        nbkg_guess = np.sum(hist) - nsig_guess
+        return [nsig_guess, mu, sigma, nbkg_guess, hstep, fit_range[0], fit_range[1], 0]
+
+
+def unbinned_energy_fit(
+    energy,
+    func,
+    gof_func,
+    gof_range,
+    fit_range=(np.inf, np.inf),
+    guess=None,
+    tol=None,
+    verbose=False,
+    display=0,
+):
+
+    """
+    Unbinned fit to energy. This is different to the default fitting as
+    it will try different fitting methods and choose the best. This is necessary for the lower statistics.
+    """
+
+    bin_width = 1
+    lower_bound = (np.nanmin(energy) // bin_width) * bin_width
+    upper_bound = ((np.nanmax(energy) // bin_width) + 1) * bin_width
+    hist1, bins, var = pgh.get_hist(
+        energy, dx=bin_width, range=(lower_bound, upper_bound)
+    )
+    bin_cs1 = (bins[:-1] + bins[1:]) / 2
+    if guess is not None:
+        x0 = [*guess[:-2], fit_range[0], fit_range[1], False]
+    else:
+        if func == pgf.extended_radford_pdf:
+            x0 = simple_guess(hist1, bins, var, pgf.extended_gauss_step_pdf, fit_range)
+            if verbose:
+                print(x0)
+            c = cost.ExtendedUnbinnedNLL(energy, pgf.extended_gauss_step_pdf)
+            m = Minuit(c, *x0)
+            m.fixed[-3:] = True
+            m.simplex().migrad()
+            m.hesse()
+            if guess is not None:
+                x0_rad = [*guess[:-2], fit_range[0], fit_range[1], False]
+            else:
+                x0_rad = simple_guess(hist1, bins, var, func, fit_range)
+            x0 = m.values[:3]
+            x0 += x0_rad[3:5]
+            x0 += m.values[3:]
+        else:
+            x0 = simple_guess(hist1, bins, var, func, fit_range)
+    if verbose:
+        print(x0)
+    c = cost.ExtendedUnbinnedNLL(energy, func)
+    m = Minuit(c, *x0)
+    if tol is not None:
+        m.tol = tol
+    m.fixed[-3:] = True
+    m.migrad()
+    m.hesse()
+
+    hist, bins, var = pgh.get_hist(energy, dx=1, range=gof_range)
+    bin_cs = (bins[:-1] + bins[1:]) / 2
+    m_fit = func(bin_cs1, *m.values)[1]
+
+    valid1 = (
+        m.valid
+        # & m.accurate
+        & (~np.isnan(m.errors).any())
+        & (~(np.array(m.errors[:-3]) == 0).all())
+    )
+
+    cs = pgf.goodness_of_fit(
+        hist, bins, None, gof_func, m.values[:-3], method="Pearson"
+    )
+    cs = cs[0] / cs[1]
+    m2 = Minuit(c, *x0)
+    if tol is not None:
+        m2.tol = tol
+    m2.fixed[-3:] = True
+    m2.simplex().migrad()
+    m2.hesse()
+    m2_fit = func(bin_cs1, *m2.values)[1]
+    valid2 = (
+        m2.valid
+        # & m2.accurate
+        & (~np.isnan(m.errors).any())
+        & (~(np.array(m2.errors[:-3]) == 0).all())
+    )
+
+    cs2 = pgf.goodness_of_fit(
+        hist, bins, None, gof_func, m2.values[:-3], method="Pearson"
+    )
+    cs2 = cs2[0] / cs2[1]
+
+    frac_errors1 = np.sum(np.abs(np.array(m.errors)[:-3] / np.array(m.values)[:-3]))
+    frac_errors2 = np.sum(np.abs(np.array(m2.errors)[:-3] / np.array(m2.values)[:-3]))
+
+    if verbose:
+        print(m)
+        print(m2)
+        print(frac_errors1, frac_errors2)
+
+    if display > 1:
+        m_fit = gof_func(bin_cs1, *m.values)
+        m2_fit = gof_func(bin_cs1, *m2.values)
+        plt.figure()
+        plt.plot(bin_cs1, hist1, label=f"hist")
+        plt.plot(bin_cs1, func(bin_cs1, *x0)[1], label=f"Guess")
+        plt.plot(bin_cs1, m_fit, label=f"Fit 1: {cs}")
+        plt.plot(bin_cs1, m2_fit, label=f"Fit 2: {cs2}")
+        plt.legend()
+        plt.show()
+
+    if valid1 == False and valid2 == False:
+        log.debug("Extra simplex needed")
+        m = Minuit(c, *x0)
+        if tol is not None:
+            m.tol = tol
+        m.fixed[-3:] = True
+        m.limits = pgc.get_hpge_E_bounds(func)
+        m.simplex().simplex().migrad()
+        m.hesse()
+        if verbose:
+            print(m)
+        cs = pgf.goodness_of_fit(
+            hist, bins, None, gof_func, m.values[:-3], method="Pearson"
+        )
+        cs = cs[0] / cs[1]
+        valid3 = (
+            m.valid
+            # & m.accurate
+            & (~np.isnan(m.errors).any())
+            & (~(np.array(m.errors[:-3]) == 0).all())
+        )
+        if valid3 == False:
+            raise RuntimeError
+
+        pars = np.array(m.values)[:-1]
+        errs = np.array(m.errors)[:-1]
+        cov = np.array(m.covariance)[:-1, :-1]
+        csqr = cs
+
+    elif valid2 == False or cs * 1.05 < cs2:
+        pars = np.array(m.values)[:-1]
+        errs = np.array(m.errors)[:-3]
+        cov = np.array(m.covariance)[:-1, :-1]
+        csqr = cs
+
+    elif valid1 == False or cs2 * 1.05 < cs:
+        pars = np.array(m2.values)[:-1]
+        errs = np.array(m2.errors)[:-3]
+        cov = np.array(m2.covariance)[:-1, :-1]
+        csqr = cs2
+
+    elif frac_errors1 < frac_errors2:
+        pars = np.array(m.values)[:-1]
+        errs = np.array(m.errors)[:-3]
+        cov = np.array(m.covariance)[:-1, :-1]
+        csqr = cs
+
+    elif frac_errors1 > frac_errors2:
+        pars = np.array(m2.values)[:-1]
+        errs = np.array(m2.errors)[:-3]
+        cov = np.array(m2.covariance)[:-1, :-1]
+        csqr = cs2
+
+    else:
+        raise RuntimeError
+
+    return pars, errs, cov, csqr
+
+
+def get_peak_fwhm_with_dt_corr(
+    Energies,
+    alpha,
+    dt,
+    func,
+    gof_func,
+    peak,
+    kev_width,
+    guess=None,
+    kev=False,
+    display=0,
+):
+
+    """
+    Applies the drift time correction and fits the peak returns the fwhm, fwhm/max and associated errors,
+    along with the number of signal events and the reduced chi square of the fit. Can return result in ADC or keV.
+    """
+
+    correction = np.multiply(
+        np.multiply(alpha, dt, dtype="float64"), Energies, dtype="float64"
+    )
+    ct_energy = np.add(correction, Energies)
+
+    bin_width = 1
+    lower_bound = (np.nanmin(ct_energy) // bin_width) * bin_width
+    upper_bound = ((np.nanmax(ct_energy) // bin_width) + 1) * bin_width
+    hist, bins, var = pgh.get_hist(
+        ct_energy, dx=bin_width, range=(lower_bound, upper_bound)
+    )
+    mu = bins[np.nanargmax(hist)]
+    adc_to_kev = mu / peak
+    # Making the window slightly smaller removes effects where as mu moves edge can be outside bin width
+    lower_bound = mu - ((kev_width[0] - 2) * adc_to_kev)
+    upper_bound = mu + ((kev_width[1] - 2) * adc_to_kev)
+    win_idxs = (ct_energy > lower_bound) & (ct_energy < upper_bound)
+    fit_range = (lower_bound, upper_bound)
+    if peak > 1500:
+        gof_range = (mu - (7 * adc_to_kev), mu + (7 * adc_to_kev))
+    else:
+        gof_range = (mu - (5 * adc_to_kev), mu + (5 * adc_to_kev))
+    # if kev==True:
+    # else:
+    #    tol=0.01
+    tol = None
+    try:
+        if display > 0:
+            energy_pars, energy_err, cov, chisqr = unbinned_energy_fit(
+                ct_energy[win_idxs],
+                func,
+                gof_func,
+                gof_range,
+                fit_range,
+                tol=tol,
+                guess=guess,
+                verbose=True,
+                display=display,
+            )
+            print(energy_pars)
+            print(energy_err)
+            print(cov)
+            plt.figure()
+            xs = np.arange(lower_bound, upper_bound, bin_width)
+            hist, bins, var = pgh.get_hist(
+                ct_energy, dx=bin_width, range=(lower_bound, upper_bound)
+            )
+            plt.plot((bins[1:] + bins[:-1]) / 2, hist)
+            plt.plot(xs, gof_func(xs, *energy_pars))
+            plt.show()
+        else:
+            energy_pars, energy_err, cov, chisqr = unbinned_energy_fit(
+                ct_energy[win_idxs],
+                func,
+                gof_func,
+                gof_range,
+                fit_range,
+                guess=guess,
+                tol=tol,
+            )
+        if func == pgf.extended_radford_pdf:
+            if energy_pars[3] < 1e-6 and energy_err[3] < 1e-6:
+                fwhm = energy_pars[2] * 2 * np.sqrt(2 * np.log(2))
+                fwhm_err = np.sqrt(cov[2][2]) * 2 * np.sqrt(2 * np.log(2))
+            else:
+                fwhm = pgf.radford_fwhm(energy_pars[2], energy_pars[3], energy_pars[4])
+
+        elif func == pgf.extended_gauss_step_pdf:
+            fwhm = energy_pars[2] * 2 * np.sqrt(2 * np.log(2))
+            fwhm_err = np.sqrt(cov[2][2]) * 2 * np.sqrt(2 * np.log(2))
+
+        xs = np.arange(lower_bound, upper_bound, 0.1)
+        y = func(xs, *energy_pars)[1]
+        max_val = np.amax(y)
+
+        fwhm_o_max = fwhm / max_val
+
+        rng = np.random.default_rng(1)
+        # generate set of bootstrapped parameters
+        par_b = rng.multivariate_normal(energy_pars, cov, size=100)
+        y_max = np.array([func(xs, *p)[1] for p in par_b])
+        maxs = np.nanmax(y_max, axis=1)
+
+        yerr_boot = np.nanstd(y_max, axis=0)
+
+        if func == pgf.extended_radford_pdf and not (
+            energy_pars[3] < 1e-6 and energy_err[3] < 1e-6
+        ):
+            y_b = np.zeros(len(par_b))
+            for i, p in enumerate(par_b):
+                try:
+                    y_b[i] = pgf.radford_fwhm(p[2], p[3], p[4])  #
+                except:
+                    y_b[i] = np.nan
+            fwhm_err = np.nanstd(y_b, axis=0)
+            if fwhm_err == 0:
+                fwhm, fwhm_err = pgf.radford_fwhm(
+                    energy_pars[2],
+                    energy_pars[3],
+                    energy_pars[4],
+                    cov=cov[:, :-2][:-2, :],
+                )
+            fwhm_o_max_err = np.nanstd(y_b / maxs, axis=0)
+        else:
+            max_err = np.nanstd(maxs)
+            fwhm_o_max_err = fwhm_o_max * np.sqrt(
+                (np.array(fwhm_err) / np.array(fwhm)) ** 2
+                + (np.array(max_err) / np.array(max_val)) ** 2
+            )
+
+        if display > 1:
+            plt.figure()
+            plt.plot((bins[1:] + bins[:-1]) / 2, hist)
+            for i in range(100):
+                plt.plot(xs, y_max[i, :])
+            plt.show()
+
+        if display > 0:
+            plt.figure()
+            hist, bins, var = pgh.get_hist(
+                ct_energy, dx=bin_width, range=(lower_bound, upper_bound)
+            )
+            plt.plot((bins[1:] + bins[:-1]) / 2, hist)
+            plt.plot(xs, gof_func(xs, *energy_pars))
+            plt.fill_between(
+                xs, y - yerr_boot, y + yerr_boot, facecolor="C1", alpha=0.5
+            )
+            plt.show()
+
+    except:
+        return np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, np.nan, None
+
+    if kev == True:
+        fwhm *= peak / energy_pars[1]
+        fwhm_err *= peak / energy_pars[1]
+
+    return (
+        fwhm,
+        fwhm_o_max,
+        fwhm_err,
+        fwhm_o_max_err,
+        chisqr,
+        energy_pars[0],
+        energy_err[0],
+        energy_pars,
+    )
+
+
+def fom_FWHM_with_dt_corr_fit(tb_in, kwarg_dict, ctc_parameter, idxs=None, display=0):
+    """
+    FOM for sweeping over ctc values to find the best value, returns the best found fwhm with its error,
+    the corresponding alpha value and the number of events in the fitted peak, also the reduced chisquare of the
+    """
+    parameter = kwarg_dict["parameter"]
+    func = kwarg_dict["func"]
+    gof_func = kwarg_dict["gof_func"]
+    Energies = tb_in[parameter].nda
+    Energies = Energies.astype("float64")
+    peak = kwarg_dict["peak"]
+    kev_width = kwarg_dict["kev_width"]
+    min_alpha = 0
+    max_alpha = 3.50e-06
+    astep = 1.250e-07
+    if ctc_parameter == "QDrift":
+        dt = tb_in["dt_eff"].nda
+    elif ctc_parameter == "dt":
+        dt = np.subtract(tb_in["tp_99"].nda, tb_in["tp_0_est"].nda, dtype="float64")
+    elif ctc_parameter == "rt":
+        dt = np.subtract(tb_in["tp_99"].nda, tb_in["tp_01"].nda, dtype="float64")
+    if np.isnan(Energies).any():
+        return {
+            "fwhm": np.nan,
+            "fwhm_err": np.nan,
+            "alpha": np.nan,
+            "alpha_err": np.nan,
+            "chisquare": np.nan,
+            "n_sig": np.nan,
+            "n_sig_err": np.nan,
+        }
+    if np.isnan(dt).any():
+        return {
+            "fwhm": np.nan,
+            "fwhm_err": np.nan,
+            "alpha": np.nan,
+            "alpha_err": np.nan,
+            "chisquare": np.nan,
+            "n_sig": np.nan,
+            "n_sig_err": np.nan,
+        }
+
+    if idxs is not None:
+        Energies = Energies[idxs]
+        dt = dt[idxs]
+
+    alphas = np.array(
+        [
+            0.000e00,
+            1.250e-07,
+            2.500e-07,
+            3.750e-07,
+            5.000e-07,
+            6.250e-07,
+            7.500e-07,
+            8.750e-07,
+            1.000e-06,
+            1.125e-06,
+            1.250e-06,
+            1.375e-06,
+            1.500e-06,
+            1.625e-06,
+            1.750e-06,
+            1.875e-06,
+            2.000e-06,
+            2.125e-06,
+            2.250e-06,
+            2.375e-06,
+            2.500e-06,
+            2.625e-06,
+            2.750e-06,
+            2.875e-06,
+            3.000e-06,
+            3.125e-06,
+            3.250e-06,
+            3.375e-06,
+            3.500e-06,
+        ],
+        dtype="float64",
+    )
+    fwhms = np.array([])
+    final_alphas = np.array([])
+    fwhm_errs = np.array([])
+    guess = None
+    best_fwhm = np.inf
+    for alpha in alphas:
+        (
+            _,
+            fwhm_o_max,
+            _,
+            fwhm_o_max_err,
+            _,
+            _,
+            _,
+            fit_pars,
+        ) = get_peak_fwhm_with_dt_corr(
+            Energies, alpha, dt, func, gof_func, peak, kev_width, guess=guess
+        )
+        if not np.isnan(fwhm_o_max):
+            fwhms = np.append(fwhms, fwhm_o_max)
+            final_alphas = np.append(final_alphas, alpha)
+            fwhm_errs = np.append(fwhm_errs, fwhm_o_max_err)
+            guess = fit_pars
+            if fwhms[-1] < best_fwhm:
+                best_fwhm = fwhms[-1]
+                best_fit = fit_pars
+        log.info(f"alpha: {alpha}, fwhm/max:{fwhm_o_max}+-{fwhm_o_max_err}")
+
+    # Make sure fit isn't based on only a few points
+    if len(fwhms) < 10:
+        log.error("less than 10 fits successful")
+        return {
+            "fwhm": np.nan,
+            "fwhm_err": np.nan,
+            "alpha": np.nan,
+            "alpha_err": np.nan,
+            "chisquare": np.nan,
+            "n_sig": np.nan,
+            "n_sig_err": np.nan,
+        }
+
+    ids = (fwhm_errs < 2 * np.nanpercentile(fwhm_errs, 50)) & (fwhm_errs > 0)
+    # Fit alpha curve to get best alpha
+
+    try:
+        alphas = np.arange(
+            final_alphas[ids][0], final_alphas[ids][-1], astep / 20, dtype="float64"
+        )
+        alpha_fit, cov = np.polyfit(
+            final_alphas[ids], fwhms[ids], w=1 / fwhm_errs[ids], deg=4, cov=True
+        )
+        fit_vals = np.polynomial.polynomial.polyval(alphas, alpha_fit[::-1])
+        alpha = alphas[np.nanargmin(fit_vals)]
+
+        rng = np.random.default_rng(1)
+        alpha_pars_b = rng.multivariate_normal(alpha_fit, cov, size=1000)
+        fits = np.array(
+            [
+                np.polynomial.polynomial.polyval(alphas, pars[::-1])
+                for pars in alpha_pars_b
+            ]
+        )
+        min_alphas = np.array([alphas[np.nanargmin(fit)] for fit in fits])
+        alpha_err = np.nanstd(min_alphas)
+        if display > 0:
+            plt.figure()
+            yerr_boot = np.std(fits, axis=0)
+            plt.errorbar(final_alphas, fwhms, yerr=fwhm_errs, linestyle=" ")
+            plt.plot(alphas, fit_vals)
+            plt.fill_between(
+                alphas,
+                fit_vals - yerr_boot,
+                fit_vals + yerr_boot,
+                facecolor="C1",
+                alpha=0.5,
+            )
+            plt.show()
+
+    except:
+        log.error("alpha fit failed")
+        return {
+            "fwhm": np.nan,
+            "fwhm_err": np.nan,
+            "alpha": np.nan,
+            "alpha_err": np.nan,
+            "chisquare": np.nan,
+            "n_sig": np.nan,
+            "n_sig_err": np.nan,
+        }
+
+    if np.isnan(fit_vals).all():
+        log.error("alpha fit all nan")
+        return {
+            "fwhm": np.nan,
+            "fwhm_err": np.nan,
+            "alpha": np.nan,
+            "alpha_err": np.nan,
+            "chisquare": np.nan,
+            "n_sig": np.nan,
+            "n_sig_err": np.nan,
+        }
+
+    else:
+        # Return fwhm of optimal alpha in kev with error
+        (
+            final_fwhm,
+            _,
+            final_err,
+            _,
+            csqr,
+            n_sig,
+            n_sig_err,
+            _,
+        ) = get_peak_fwhm_with_dt_corr(
+            Energies,
+            alpha,
+            dt,
+            func,
+            gof_func,
+            peak,
+            kev_width,
+            guess=best_fit,
+            kev=True,
+            display=display,
+        )
+        if np.isnan(final_fwhm) or np.isnan(final_err):
+            (
+                final_fwhm,
+                _,
+                final_err,
+                _,
+                csqr,
+                n_sig,
+                n_sig_err,
+                _,
+            ) = get_peak_fwhm_with_dt_corr(
+                Energies,
+                alpha,
+                dt,
+                func,
+                gof_func,
+                peak,
+                kev_width,
+                kev=True,
+                display=display,
+            )
+        if np.isnan(final_fwhm) or np.isnan(final_err):
+            log.error(f"final fit failed, alpha was {alpha}")
+        return {
+            "fwhm": final_fwhm,
+            "fwhm_err": final_err,
+            "alpha": alpha,
+            "alpha_err": alpha_err,
+            "chisquare": csqr,
+            "n_sig": n_sig,
+            "n_sig_err": n_sig_err,
+        }
+
+
+def fom_all_fit(tb_in, kwarg_dict):
+    """
+    FOM to run over different ctc parameters
+    """
+    ctc_parameters = ["QDrift"]  #'dt',
+    output_dict = {}
+    for param in ctc_parameters:
+        out = fom_FWHM_with_dt_corr_fit(tb_in, kwarg_dict, param)
+        output_dict[param] = out
+    return output_dict
+
+
+def fom_FWHM_fit(tb_in, kwarg_dict):
+    """
+    FOM with no ctc sweep, used for optimising ftp.
+    """
+    parameter = kwarg_dict["parameter"]
+    func = kwarg_dict["func"]
+    gof_func = kwarg_dict["gof_func"]
+    Energies = tb_in[parameter].nda
+    Energies = Energies.astype("float64")
+    peak = kwarg_dict["peak"]
+    kev_width = kwarg_dict["kev_width"]
+    try:
+        alpha = kwarg_dict["alpha"]
+        if isinstance(alpha, dict):
+            alpha = alpha[parameter]
+    except KeyError:
+        alpha = 0
+    try:
+        ctc_param = kwarg_dict["ctc_param"]
+        dt = tb_in[ctc_param].nda
+    except KeyError:
+        dt = 0
+
+    if np.isnan(Energies).any():
+        return {"fwhm": np.nan, "fwhm_err": np.nan}
+
+    (
+        _,
+        final_fwhm_o_max,
+        _,
+        final_fwhm_o_max_err,
+        csqr,
+        n_sig,
+        n_sig_err,
+    ) = get_peak_fwhm_with_dt_corr(
+        Energies, alpha, dt, func, gof_func, peak=peak, kev_width=kev_width, kev=True
+    )
+    return {
+        "fwhm_o_max": final_fwhm_o_max,
+        "max_o_fwhm": final_fwhm_o_max_err,
+        "chisquare": csqr,
+        "n_sig": n_sig,
+        "n_sig_err": n_sig_err,
+    }
+
+
+def get_wf_indexes(sorted_indexs, n_events):
+    out_list = []
+    if isinstance(n_events, list):
+        for i in range(len(n_events)):
+            new_list = []
+            for idx, entry in enumerate(sorted_indexs):
+                if (entry >= np.sum(n_events[:i])) and (
+                    entry < np.sum(n_events[: i + 1])
+                ):
+                    new_list.append(idx)
+            out_list.append(new_list)
+    else:
+        for i in range(int(len(sorted_indexs) / n_events)):
+            new_list = []
+            for idx, entry in enumerate(sorted_indexs):
+                if (entry >= i * n_events) and (entry < (i + 1) * n_events):
+                    new_list.append(idx)
+            out_list.append(new_list)
+    return out_list
+
+
+def index_data(data, indexes):
+    new_baselines = lh5.Array(data["baseline"].nda[indexes])
+    new_waveform_values = data["waveform"]["values"].nda[indexes]
+    new_waveform_dts = data["waveform"]["dt"].nda[indexes]
+    new_waveform_t0 = data["waveform"]["t0"].nda[indexes]
+    new_waveform = lh5.WaveformTable(
+        None, new_waveform_t0, "ns", new_waveform_dts, "ns", new_waveform_values
+    )
+    new_data = lh5.Table(col_dict={"waveform": new_waveform, "baseline": new_baselines})
+    return new_data
+
+
+def event_selection(
+    raw_files,
+    lh5_path,
+    dsp_config,
+    db_dict,
+    peaks_keV,
+    peak_idxs,
+    kev_widths,
+    cut_parameters={"bl_mean": 4, "bl_std": 4, "pz_std": 4},
+    energy_parameter="trapTmax",
+    n_events=10000,
+    threshold=1000,
+):
+    if not isinstance(peak_idxs, list):
+        peak_idxs = [peak_idxs]
+    if not isinstance(kev_widths, list):
+        kev_widths = [kev_widths]
+
+    sto = lh5.LH5Store()
+    df = lh5.load_dfs(raw_files, ["daqenergy", "timestamp"], lh5_path)
+
+    pulser_props = cts.find_pulser_properties(df, energy="daqenergy")
+    if len(pulser_props) > 0:
+        out_df = cts.tag_pulsers(df, pulser_props, window=0.001)
+        ids = out_df.isPulser == 1
+        log.debug(f"pulser found: {pulser_props}")
+    else:
+        log.debug("no_pulser")
+        ids = np.zeros(len(df.daqenergy.values), dtype=bool)
+    # Get events around peak using raw file values
+    initial_mask = (df.daqenergy.values > threshold) & (~ids)
+    rough_energy = df.daqenergy.values[initial_mask]
+    initial_idxs = np.where(initial_mask)[0]
+
+    guess_keV = 2620 / np.nanpercentile(rough_energy, 99)
+    Euc_min = threshold / guess_keV * 0.6
+    Euc_max = 2620 / guess_keV * 1.1
+    dEuc = 1 / guess_keV
+    hist, bins, var = pgh.get_hist(rough_energy, range=(Euc_min, Euc_max), dx=dEuc)
+    detected_peaks_locs, detected_peaks_keV, roughpars = pgc.hpge_find_E_peaks(
+        hist,
+        bins,
+        var,
+        np.array([238.632, 583.191, 727.330, 860.564, 1620.5, 2614.553]),
+    )
+    log.debug(f"detected {detected_peaks_keV} keV peaks at {detected_peaks_locs}")
+
+    masks = []
+    for peak_idx in peak_idxs:
+        peak = peaks_keV[peak_idx]
+        kev_width = kev_widths[peak_idx]
+        try:
+            if peak not in detected_peaks_keV:
+                raise ValueError
+            detected_peak_idx = np.where(detected_peaks_keV == peak)[0]
+            peak_loc = detected_peaks_locs[detected_peak_idx]
+            log.info(f"{peak} peak found at {peak_loc}")
+            rough_adc_to_kev = roughpars[0]
+            e_lower_lim = peak_loc - (1.1 * kev_width[0]) / rough_adc_to_kev
+            e_upper_lim = peak_loc + (1.1 * kev_width[1]) / rough_adc_to_kev
+        except:
+            log.debug(f"{peak} peak not found attempting to use rough parameters")
+            peak_loc = (peak - roughpars[1]) / roughpars[0]
+            rough_adc_to_kev = roughpars[0]
+            e_lower_lim = peak_loc - (1.5 * kev_width[0]) / rough_adc_to_kev
+            e_upper_lim = peak_loc + (1.5 * kev_width[1]) / rough_adc_to_kev
+        log.debug(f"lower_lim:{e_lower_lim}, upper_lim:{e_upper_lim}")
+        e_mask = (rough_energy > e_lower_lim) & (rough_energy < e_upper_lim)
+        e_idxs = initial_idxs[e_mask][: int(2.5 * n_events)]
+        masks.append(e_idxs)
+        log.debug(f"{len(e_idxs)} events found in energy range for {peak}")
+
+    idx_list_lens = [len(masks[peak_idx]) for peak_idx in peak_idxs]
+
+    sort_index = np.argsort(np.concatenate(masks))
+    idx_list = get_wf_indexes(sort_index, idx_list_lens)
+    idxs = np.array(sorted(np.concatenate(masks)))
+
+    waveforms = sto.read_object(
+        f"{lh5_path}/waveform", raw_files, idx=idxs, n_rows=len(idxs)
+    )[0]
+    baseline = sto.read_object(
+        f"{lh5_path}/baseline", raw_files, idx=idxs, n_rows=len(idxs)
+    )[0]
+    input_data = lh5.Table(col_dict={"waveform": waveforms, "baseline": baseline})
+
+    if isinstance(dsp_config, str):
+        with open(dsp_config) as r:
+            dsp_config = json.load(r)
+
+    dsp_config["outputs"] = list(cut_parameters) + [energy_parameter]
+
+    log.debug("Processing data")
+    tb_data = opt.run_one_dsp(input_data, dsp_config, db_dict=db_dict)
+
+    cut_dict = cts.generate_cuts(tb_data, cut_parameters)
+    log.debug(f"Cuts are: {cut_dict}")
+    log.debug("Loaded Cuts")
+    ct_mask = cts.get_cut_indexes(tb_data, cut_dict, "raw")
+
+    final_events = []
+    for peak_idx in peak_idxs:
+        peak = peaks_keV[peak_idx]
+        kev_width = kev_widths[peak_idx]
+
+        peak_ids = np.array(idx_list[peak_idx])
+        peak_ct_mask = ct_mask[peak_ids]
+        peak_ids = peak_ids[peak_ct_mask]
+
+        energy = tb_data[energy_parameter].nda[peak_ids]
+
+        hist, bins, var = pgh.get_hist(
+            energy, range=(int(threshold), int(np.nanmax(energy))), dx=1
+        )
+        peak_loc = pgh.get_bin_centers(bins)[np.nanargmax(hist)]
+        rough_adc_to_kev = peak / peak_loc
+
+        e_lower_lim = peak_loc - (1.5 * kev_width[0]) / rough_adc_to_kev
+        e_upper_lim = peak_loc + (1.5 * kev_width[1]) / rough_adc_to_kev
+
+        e_ranges = (int(peak_loc - e_lower_lim), int(e_upper_lim - peak_loc))
+        params, errors, covs, bins, ranges, p_val = pgc.hpge_fit_E_peaks(
+            energy,
+            [peak_loc],
+            [e_ranges],
+            n_bins=(np.nanmax(energy) - np.nanmin(energy)) // 1,
+        )
+        if params[0] is None:
+            log.debug("Fit failed, using max guess")
+            hist, bins, var = pgh.get_hist(
+                energy, range=(int(e_lower_lim), int(e_upper_lim)), dx=1
+            )
+            params = [[0, pgh.get_bin_centers(bins)[np.nanargmax(hist)], 0, 0, 0, 0]]
+        updated_adc_to_kev = peak / params[0][1]
+        e_lower_lim = params[0][1] - (kev_width[0]) / updated_adc_to_kev
+        e_upper_lim = params[0][1] + (kev_width[1]) / updated_adc_to_kev
+        log.info(f"lower lim is :{e_lower_lim}, upper lim is {e_upper_lim}")
+        final_mask = (energy > e_lower_lim) & (energy < e_upper_lim)
+        final_events.append(peak_ids[final_mask][:n_events])
+        log.info(f"{len(peak_ids[final_mask][:n_events])} passed selections for {peak}")
+
+    sort_index = np.argsort(np.concatenate(final_events))
+    idx_list = get_wf_indexes(sort_index, [len(mask) for mask in final_events])
+    idxs = np.array(sorted(np.concatenate(final_events)))
+
+    final_data = index_data(input_data, idxs)
+    return final_data, idx_list
+
+
+def fwhm_slope(x, m0, m1, m2):
+    """
+    Fit the energy resolution curve
+    """
+    return np.sqrt(m0 + m1 * x + m2 * (x**2))
+
+
+def interpolate_energy(peak_energies, points, err_points, energy):
+
+    nan_mask = np.isnan(points) | (points < 0)
+    if len(points[~nan_mask]) < 3:
+        return np.nan, np.nan, np.nan
+    elif nan_mask[-1] == True or nan_mask[-2] == True:
+        return np.nan, np.nan, np.nan
+    else:
+        param_guess = [0.2, 0.001, 0.000001]  #
+        # param_bounds = (0, [10., 1. ])#
+        try:
+            fit_pars, fit_covs = curve_fit(
+                fwhm_slope,
+                peak_energies[~nan_mask],
+                points[~nan_mask],
+                sigma=err_points[~nan_mask],
+                p0=param_guess,
+                absolute_sigma=True,
+            )  # bounds=param_bounds,
+            fit_qbb = fwhm_slope(energy, *fit_pars)
+
+            xs = np.arange(peak_energies[0], peak_energies[-1], 0.1)
+
+            rng = np.random.default_rng(1)
+
+            # generate set of bootstrapped parameters
+            par_b = rng.multivariate_normal(fit_pars, fit_covs, size=1000)
+            qbb_vals = np.array([fwhm_slope(energy, *p) for p in par_b])
+            qbb_err = np.nanstd(qbb_vals)
+        except:
+            return np.nan, np.nan, np.nan
+
+        if nan_mask[-2] == True:
+            qbb_vals += qbb_err
+
+    return fit_qbb, qbb_err, fit_pars
+
+
+def fom_FWHM(tb_in, kwarg_dict, ctc_parameter, alpha, idxs=None, display=0):
+    """
+    FOM for sweeping over ctc values to find the best value, returns the best found fwhm
+    """
+    parameter = kwarg_dict["parameter"]
+    func = kwarg_dict["func"]
+    cs_func = kwarg_dict["gof_func"]
+    Energies = tb_in[parameter].nda
+    Energies = Energies.astype("float64")
+    peak = kwarg_dict["peak"]
+    kev_width = kwarg_dict["kev_width"]
+
+    if ctc_parameter == "QDrift":
+        dt = tb_in["dt_eff"].nda
+    elif ctc_parameter == "dt":
+        dt = np.subtract(tb_in["tp_99"].nda, tb_in["tp_0_est"].nda, dtype="float64")
+    elif ctc_parameter == "rt":
+        dt = np.subtract(tb_in["tp_99"].nda, tb_in["tp_01"].nda, dtype="float64")
+    if np.isnan(Energies).any():
+        return {"fwhm": np.nan, "fwhm_err": np.nan, "alpha": np.nan}
+    if np.isnan(dt).any():
+        return {"fwhm": np.nan, "fwhm_err": np.nan, "alpha": np.nan}
+
+    if idxs is not None:
+        Energies = Energies[idxs]
+        dt = dt[idxs]
+
+    # Return fwhm of optimal alpha in kev with error
+    try:
+        (
+            final_fwhm,
+            _,
+            final_err,
+            _,
+            csqr,
+            n_sig,
+            n_sig_err,
+            _,
+        ) = get_peak_fwhm_with_dt_corr(
+            Energies,
+            alpha,
+            dt,
+            func,
+            cs_func,
+            peak,
+            kev_width,
+            kev=True,
+            display=display,
+        )
+    except:
+        final_fwhm = np.nan
+        final_err = np.nan
+        csqr = np.nan
+        n_sig = np.nan
+        n_sig_err = np.nan
+    return {
+        "fwhm": final_fwhm,
+        "fwhm_err": final_err,
+        "alpha": alpha,
+        "chisquare": csqr,
+        "n_sig": n_sig,
+        "n_sig_err": n_sig_err,
+    }
+
+
+def single_peak_fom(data, kwarg_dict):
+    peaks = kwarg_dict["peaks_keV"]
+    idx_list = kwarg_dict["idx_list"]
+    ctc_param = kwarg_dict["ctc_param"]
+    peak_dicts = kwarg_dict["peak_dicts"]
+
+    out_dict = fom_FWHM_with_dt_corr_fit(
+        data, peak_dicts[0], ctc_param, idxs=idx_list[0], display=0
+    )
+    out_dict["y_val"] = out_dict["fwhm"]
+    return out_dict
+
+
+def new_fom(data, kwarg_dict):
+
+    peaks = kwarg_dict["peaks_keV"]
+    idx_list = kwarg_dict["idx_list"]
+    ctc_param = kwarg_dict["ctc_param"]
+
+    peak_dicts = kwarg_dict["peak_dicts"]
+
+    out_dict = fom_FWHM_with_dt_corr_fit(
+        data, peak_dicts[-1], ctc_param, idxs=idx_list[-1], display=0
+    )
+    alpha = out_dict["alpha"]
+    log.info(alpha)
+    fwhms = []
+    fwhm_errs = []
+    n_sig = []
+    n_sig_err = []
+    for i, peak in enumerate(peaks[:-1]):
+        out_peak_dict = fom_FWHM(
+            data, peak_dicts[i], ctc_param, alpha, idxs=idx_list[i], display=0
+        )
+        # n_sig_minimum = peak_dicts[i]["n_sig_minimum"]
+        # if peak_dict["n_sig"]<n_sig_minimum:
+        #    out_peak_dict['fwhm'] = np.nan
+        #   out_peak_dict['fwhm_err'] = np.nan
+        fwhms.append(out_peak_dict["fwhm"])
+        fwhm_errs.append(out_peak_dict["fwhm_err"])
+        n_sig.append(out_peak_dict["n_sig"])
+        n_sig_err.append(out_peak_dict["n_sig_err"])
+    fwhms.append(out_dict["fwhm"])
+    fwhm_errs.append(out_dict["fwhm_err"])
+    n_sig.append(out_dict["n_sig"])
+    n_sig_err.append(out_dict["n_sig_err"])
+    log.info(f"fwhms are {fwhms}keV +- {fwhm_errs}")
+    qbb, qbb_err, fit_pars = interpolate_energy(
+        np.array(peaks), np.array(fwhms), np.array(fwhm_errs), 2039
+    )
+
+    log.info(f"Qbb fwhm is {qbb} keV +- {qbb_err}")
+
+    return {
+        "y_val": qbb,
+        "qbb_fwhm": qbb,
+        "qbb_fwhm_err": qbb_err,
+        "alpha": alpha,
+        "peaks": peaks.tolist(),
+        "fwhms": fwhms,
+        "fwhm_errs": fwhm_errs,
+        "n_events": n_sig,
+        "n_sig_err": n_sig_err,
+    }
+
+
+OptimiserDimension = namedtuple(
+    "OptimiserDimension", "name parameter min_val max_val unit"
+)
+
+
+class BayesianOptimizer:
+
+    lambda_param = 0.01
+    eta_param = 0
+    kernel = None
+    # FIXME: the following throws a TypeError
+    # kernel=ConstantKernel(1.0, constant_value_bounds="fixed") * RBF(1, length_scale_bounds="fixed") #+ WhiteKernel(noise_level=0.0111)
+
+    def __init__(self, acq_func, batch_size):
+
+        self.dims = []
+        self.current_iter = 0
+
+        self.batch_size = batch_size
+        self.iters = 0
+
+        self.gauss_pr = GaussianProcessRegressor(kernel=self.kernel)
+        self.best_samples_ = pd.DataFrame(columns=["x", "y", "ei"])
+        self.distances_ = []
+
+        if acq_func == "ei":
+            self.acq_function = self._get_expected_improvement
+        elif acq_func == "ucb":
+            self.acq_function = self._get_ucb
+        elif acq_func == "lcb":
+            self.acq_function = self._get_lcb
+
+    def add_dimension(self, name, parameter, min_val, max_val, unit=None):
+        self.dims.append(OptimiserDimension(name, parameter, min_val, max_val, unit))
+
+    def get_n_dimensions(self):
+        return len(self.dims)
+
+    def add_initial_values(self, x_init, y_init):
+        self.x_init = x_init
+        self.y_init = y_init
+
+    def _get_expected_improvement(self, x_new):
+
+        # Using estimate from Gaussian surrogate instead of actual function for
+        # a new trial data point to avoid cost
+
+        mean_y_new, sigma_y_new = self.gauss_pr.predict(
+            np.array([x_new]), return_std=True
+        )
+        sigma_y_new = sigma_y_new.reshape(-1, 1)[0]
+
+        # Using estimates from Gaussian surrogate instead of actual function for
+        # entire prior distribution to avoid cost
+
+        mean_y = self.gauss_pr.predict(self.x_init)
+        min_mean_y = np.min(mean_y)
+        z = (mean_y_new - min_mean_y - self.eta_param) / (sigma_y_new + 1e-9)  #
+        exp_imp = (mean_y_new - min_mean_y - self.eta_param) * norm.cdf(
+            z
+        ) + sigma_y_new * norm.pdf(z)
+        return exp_imp
+
+    def _get_ucb(self, x_new):
+
+        # Using estimate from Gaussian surrogate instead of actual function for
+        # a new trial data point to avoid cost
+
+        mean_y_new, sigma_y_new = self.gauss_pr.predict(
+            np.array([x_new]), return_std=True
+        )
+        sigma_y_new = sigma_y_new.reshape(-1, 1)[0]
+
+        return mean_y_new + self.lambda_param * sigma_y_new
+
+    def _get_lcb(self, x_new):
+
+        # Using estimate from Gaussian surrogate instead of actual function for
+        # a new trial data point to avoid cost
+
+        mean_y_new, sigma_y_new = self.gauss_pr.predict(
+            np.array([x_new]), return_std=True
+        )
+        sigma_y_new = sigma_y_new.reshape(-1, 1)[0]
+
+        return mean_y_new - self.lambda_param * sigma_y_new
+
+    def _acquisition_function(self, x):
+        return self.acq_function(x)
+
+    def _get_next_probable_point(self):
+        min_ei = float(sys.maxsize)
+        x_optimal = None
+        np.random.seed(55)
+        # Trial with an array of random data points
+        rands = np.random.uniform(
+            np.array([dim.min_val for dim in self.dims]),
+            np.array([dim.max_val for dim in self.dims]),
+            (self.batch_size, self.get_n_dimensions()),
+        )
+        for x_start in rands:
+            response = minimize(
+                fun=self._acquisition_function,
+                x0=x_start,
+                bounds=[(dim.min_val, dim.max_val) for dim in self.dims],
+                method="L-BFGS-B",
+            )
+            if response.fun[0] < min_ei:
+                min_ei = response.fun[0]
+                x_optimal = [y.round(2) for y in response.x]
+        if x_optimal in self.x_init and self.iters < 5:
+            self.iters += 1
+            x_optimal, min_ei = self._get_next_probable_point()
+
+        if x_optimal in self.x_init and self.iters == 5:
+            x_optimal += np.random.uniform(
+                np.array([0.1 for dim in self.dims]),
+                np.array([1 for dim in self.dims]),
+                (1, len(self.dims)),
+            )
+            x_optimal = [y.round(2) for y in x_optimal[0]]
+        return x_optimal, min_ei
+
+    def _extend_prior_with_posterior_data(self, x, y):
+        self.x_init = np.append(self.x_init, np.array([x]), axis=0)
+        self.y_init = np.append(self.y_init, np.array(y), axis=0)
+
+    def get_first_point(self):
+        y_min_ind = np.nanargmin(self.y_init)
+        self.y_min = self.y_init[y_min_ind]
+        self.optimal_x = self.x_init[y_min_ind]
+        self.optimal_ei = None
+        return self.optimal_x, self.optimal_ei
+
+    def iterate_values(self):
+        nan_idxs = np.isnan(self.y_init)
+        self.gauss_pr.fit(self.x_init[~nan_idxs], np.array(self.y_init)[~nan_idxs])
+        x_next, ei = self._get_next_probable_point()
+        return x_next, ei
+
+    def update_db_dict(self, db_dict):
+        if self.current_iter == 0:
+            x_new, ei = self.get_first_point()
+        x_new, ei = self.iterate_values()
+        self.current_x = x_new
+        self.current_ei = ei
+        for i, val in enumerate(x_new):
+            name, parameter, min_val, max_val, unit = self.dims[i]
+            if unit is not None:
+                value_str = f"{val}*{unit}"
+            else:
+                value_str = f"{val}"
+            if name not in db_dict.keys():
+                db_dict[name] = {parameter: value_str}
+            else:
+                db_dict[name][parameter] = value_str
+        self.current_iter += 1
+        return db_dict
+
+    def update(self, results):
+        y_val = results["y_val"]
+        self._extend_prior_with_posterior_data(self.current_x, np.array([y_val]))
+
+        if np.isnan(y_val):
+            pass
+        else:
+            if y_val < self.y_min:
+                self.y_min = y_val
+                self.optimal_x = self.current_x
+                self.optimal_ei = self.current_ei
+                self.optimal_results = results
+
+        if self.current_iter == 1:
+            self.prev_x = self.current_x
+        else:
+            self.distances_.append(
+                np.linalg.norm(np.array(self.prev_x) - np.array(self.current_x))
+            )
+            self.prev_x = self.current_x
+
+        self.best_samples_ = self.best_samples_.append(
+            {"y": self.y_min, "ei": self.optimal_ei}, ignore_index=True
+        )
+
+    def get_best_vals(self):
+        out_dict = {}
+        for i, val in enumerate(self.optimal_x):
+            name, parameter, min_val, max_val, unit = self.dims[i]
+            value_str = f"{val}*{unit}"
+            if name not in out_dict.keys():
+                out_dict[name] = {parameter: value_str}
+            else:
+                out_dict[name][parameter] = value_str
+        return out_dict
+
+
+def run_optimisation(
+    tb_data,
+    dsp_config,
+    fom_function,
+    optimisers,
+    fom_kwargs=None,
+    db_dict=None,
+    nan_val=10,
+    n_iter=10,
+):
+    if not isinstance(optimisers, list):
+        optimisers = [optimisers]
+    if not isinstance(fom_kwargs, list):
+        fom_kwargs = [fom_kwargs]
+    if not isinstance(fom_function, list):
+        fom_function = [fom_function]
+
+    for j in range(n_iter):
+        for optimiser in optimisers:
+            db_dict = optimiser.update_db_dict(db_dict)
+
+        log.info(f"Iteration number: {j+1}")
+        log.info(f"Processing with {db_dict}")
+
+        tb_out = opt.run_one_dsp(tb_data, dsp_config, db_dict=db_dict)
+
+        res = np.ndarray(shape=len(optimisers), dtype="O")
+
+        for i in range(len(optimisers)):
+            if fom_kwargs[i] is not None:
+                if len(fom_function) > 1:
+                    res[i] = fom_function[i](tb_out, fom_kwargs[i])
+                else:
+                    res[i] = fom_function[0](tb_out, fom_kwargs[i])
+            else:
+                if len(fom_function) > 1:
+                    res[i] = fom_function[i](tb_out)
+                else:
+                    res[i] = fom_function[0](tb_out)
+
+        log.info(f"Results of iteration {j+1} are {res}")
+
+        for i, optimiser in enumerate(optimisers):
+            if np.isnan(res[i]["y_val"]):
+                if isinstance(nan_val, list):
+                    res[i]["y_val"] = nan_val[i]
+                else:
+                    res[i]["y_val"] = nan_val
+
+            optimiser.update(res[i])
+
+    out_param_dict = {}
+    out_results_list = []
+    for optimiser in optimisers:
+        param_dict = optimiser.get_best_vals()
+        out_param_dict.update(param_dict)
+        results_dict = optimiser.optimal_results
+        out_results_list.append(results_dict)
+
+    return out_param_dict, out_results_list
+
+
+def get_ctc_grid(grids, ctc_param):
+    """
+    Reshapes optimizer grids to be in easier form
+    """
+    error_grids = []
+    dt_grids = []
+    alpha_grids = []
+    alpha_error_grids = []
+    nevents_grids = []
+    for grid in grids:
+        shape = grid.shape
+        dt_grid = np.ndarray(shape=shape)
+        alpha_grid = np.ndarray(shape=shape)
+        error_grid = np.ndarray(shape=shape)
+        alpha_error_grid = np.ndarray(shape=shape)
+        nevents_grid = np.ndarray(shape=shape)
+        for i in range(shape[0]):
+            for j in range(shape[1]):
+                dt_grid[i, j] = grid[i, j][ctc_param]["fwhm"]
+                error_grid[i, j] = grid[i, j][ctc_param]["fwhm_err"]
+                nevents_grid[i, j] = grid[i, j][ctc_param]["n_sig"]
+                try:
+                    alpha_grid[i, j] = grid[i, j][ctc_param]["alpha"]
+                except:
+                    pass
+                try:
+                    alpha_error_grid[i, j] = grid[i, j][ctc_param]["alpha_err"]
+                except:
+                    pass
+        dt_grids.append(dt_grid)
+        alpha_grids.append(alpha_grid)
+        error_grids.append(error_grid)
+        alpha_error_grids.append(alpha_error_grid)
+        nevents_grids.append(nevents_grid)
+    return dt_grids, error_grids, alpha_grids, alpha_error_grids, nevents_grids
+
+
+def interpolate_energy_old(peak_energies, grids, error_grids, energy, nevents_grids):
+    """
+    Interpolates fwhm vs energy for every grid point
+    """
+
+    grid_no = len(grids)
+    grid_shape = grids[0].shape
+    out_grid = np.empty(grid_shape)
+    out_grid_err = np.empty(grid_shape)
+    n_event_lim = np.array(
+        [0.98 * np.nanpercentile(nevents_grid, 50) for nevents_grid in nevents_grids]
+    )
+    for index, x in np.ndenumerate(grids[0]):
+        points = np.array([grids[i][index] for i in range(len(grids))])
+        err_points = np.array([error_grids[i][index] for i in range(len(grids))])
+        n_sigs = np.array([nevents_grids[i][index] for i in range(len(grids))])
+        nan_mask = (
+            np.isnan(points)
+            | (points < 0)
+            | (0.1 * points < err_points)
+            | (n_sigs < n_event_lim)
+        )
+        try:
+            if len(points[nan_mask]) > 2:
+                raise ValueError
+            elif nan_mask[-1] == True or nan_mask[-2] == True:
+                raise ValueError
+            param_guess = [0.2, 0.001, 0.000001]
+            param_bounds = param_bounds = (0, [1, np.inf, np.inf])  # ,0.1
+            fit_pars, fit_covs = curve_fit(
+                fwhm_slope,
+                peak_energies[~nan_mask],
+                points[~nan_mask],
+                sigma=err_points[~nan_mask],
+                p0=param_guess,
+                bounds=param_bounds,
+                absolute_sigma=True,
+            )  #
+            fit_qbb = fwhm_slope(energy, *fit_pars)
+            sderrs = np.sqrt(np.diag(fit_covs))
+            qbb_err = fwhm_slope(energy, *(fit_pars + sderrs)) - fwhm_slope(
+                energy, *fit_pars
+            )
+            out_grid[index] = fit_qbb
+            out_grid_err[index] = qbb_err
+        except:
+            out_grid[index] = np.nan
+            out_grid_err[index] = np.nan
+    return out_grid, out_grid_err
+
+
+def find_lowest_grid_point_save(grid, err_grid, opt_dict):
+    """
+    Finds the lowest grid point, if more than one with same value returns shortest filter.
+    """
+    opt_name = list(opt_dict.keys())[0]
+    print(opt_name)
+    keys = list(opt_dict[opt_name].keys())
+    param_list = []
+    shape = []
+    db_dict = {}
+    for key in keys:
+        param_dict = opt_dict[opt_name][key]
+        grid_axis = np.arange(
+            param_dict["start"], param_dict["end"], param_dict["spacing"]
+        )
+        unit = param_dict.get("unit")
+        param_list.append(grid_axis)
+        shape.append(len(grid_axis))
+
+    total_lengths = np.zeros(shape)
+
+    for index, x in np.ndenumerate(total_lengths):
+        for i, param in enumerate(param_list):
+            total_lengths[index] += param[index[i]]
+    min_val = np.nanmin(grid)
+    lowest_ixs = np.where(grid == min_val)
+    try:
+        fwhm_dict = {"fwhm": min_val, "fwhm_err": err_grid[lowest_ixs][0]}
+    except:
+        print(lowest_ixs)
+    if len(lowest_ixs[0]) == 1:
+        for i, key in enumerate(keys):
+            if i == 0:
+                if unit is not None:
+                    db_dict[opt_name] = {
+                        key: f"{param_list[i][lowest_ixs[i]][0]}*{unit}"
+                    }
+                else:
+                    db_dict[opt_name] = {key: f"{param_list[i][lowest_ixs[i]][0]}"}
+            else:
+                if unit is not None:
+                    db_dict[opt_name][key] = f"{param_list[i][lowest_ixs[i]][0]}*{unit}"
+                else:
+                    db_dict[opt_name][key] = f"{param_list[i][lowest_ixs[i]][0]}"
+    else:
+        shortest_length = np.argmin(total_lengths[lowest_ixs])
+        final_idxs = [lowest_ix[shortest_length] for lowest_ix in lowest_ixs]
+        for i, key in enumerate(keys):
+            if unit is not None:
+                db_dict[opt_name] = {key: f"{param_list[i][lowest_ixs[i]][0]}*{unit}"}
+            else:
+                db_dict[opt_name] = {key: f"{param_list[i][lowest_ixs[i]][0]}"}
+    return lowest_ixs, fwhm_dict, db_dict
+
+
+def interpolate_grid(energies, grids, int_energy, deg, nevents_grids):
+    """
+    Interpolates energy vs parameter for every grid point using polynomial.
+    """
+    grid_no = len(grids)
+    grid_shape = grids[0].shape
+    out_grid = np.empty(grid_shape)
+    n_event_lim = np.array(
+        [0.98 * np.nanpercentile(nevents_grid, 50) for nevents_grid in nevents_grids]
+    )
+    for index, x in np.ndenumerate(grids[0]):
+        points = np.array([grids[i][index] for i in range(len(grids))])
+        n_sigs = np.array([nevents_grids[i][index] for i in range(len(grids))])
+        nan_mask = np.isnan(points) | (points < 0) | (n_sigs < n_event_lim)
+        try:
+            if len(points[~nan_mask]) < 3:
+                raise IndexError
+            fit_point = np.polynomial.polynomial.polyfit(
+                energies[~nan_mask], points[~nan_mask], deg=deg
+            )
+            out_grid[index] = np.polynomial.polynomial.polyval(int_energy, fit_point)
+        except:
+            out_grid[index] = np.nan
+    return out_grid
+
+
+def get_best_vals(peak_grids, peak_energies, param, opt_dict, save_path=None):
+    """
+    Finds best filter parameters
+    """
+    dt_grids, error_grids, alpha_grids, alpha_error_grids, nevents_grids = get_ctc_grid(
+        peak_grids, param
+    )
+    qbb_grid, qbb_errs = interpolate_energy(
+        peak_energies, dt_grids, error_grids, 2039.061, nevents_grids
+    )
+    qbb_alphas = interpolate_grid(
+        peak_energies[2:], alpha_grids[2:], 2039.061, 1, nevents_grids[2:]
+    )
+    ixs, fwhm_dict, db_dict = find_lowest_grid_point_save(qbb_grid, qbb_errs, opt_dict)
+    out_grid = {"fwhm": qbb_grid, "fwhm_err": qbb_errs, "alphas": qbb_alphas}
+
+    if isinstance(save_path, str):
+        mpl.use("pdf")
+        e_param = list(opt_dict.keys())[0]
+        opt_dict = opt_dict[e_param]
+
+        detector = save_path.split("/")[-1]
+        save_path = os.path.join(save_path, f"{e_param}-{param}.pdf")
+        pathlib.Path(os.path.dirname(save_path)).mkdir(parents=True, exist_ok=True)
+
+        with PdfPages(save_path) as pdf:
+
+            keys = list(opt_dict.keys())
+            print(keys)
+            x_dict = opt_dict[keys[1]]
+            xvals = np.arange(x_dict["start"], x_dict["end"], x_dict["spacing"])
+            xs = (
+                np.arange(0, len(xvals), 1),
+                np.arange(x_dict["start"], x_dict["end"], x_dict["spacing"]),
+            )
+            y_dict = opt_dict[keys[0]]
+            yvals = np.arange(y_dict["start"], y_dict["end"], y_dict["spacing"])
+            ys = (
+                np.arange(0, len(yvals), 1),
+                np.arange(y_dict["start"], y_dict["end"], y_dict["spacing"]),
+            )
+            for i, x in enumerate(xs[1]):
+                xs[1][i] = round(x, 1)
+            for i, y in enumerate(ys[1]):
+                ys[1][i] = round(y, 1)
+            print(ixs)
+            points = np.array(
+                [dt_grids[i][ixs[0][0], ixs[1][0]] for i in range(len(dt_grids))]
+            )
+            err_points = np.array(
+                [error_grids[i][ixs[0][0], ixs[1][0]] for i in range(len(error_grids))]
+            )
+            alpha_points = np.array(
+                [alpha_grids[i][ixs[0][0], ixs[1][0]] for i in range(len(alpha_grids))]
+            )
+            alpha_error_points = np.array(
+                [
+                    alpha_error_grids[i][ixs[0][0], ixs[1][0]]
+                    for i in range(len(alpha_error_grids))
+                ]
+            )
+            param_guess = [0.2, 0.001, 0.000001]
+            param_bounds = (0, [1, np.inf, np.inf])  # ,0.1
+            nan_mask = np.isnan(points)
+            nan_mask = nan_mask | (points < 0) | (0.1 * points < err_points)
+            fit_pars, fit_covs = curve_fit(
+                fwhm_slope,
+                peak_energies[~nan_mask],
+                points[~nan_mask],
+                sigma=err_points[~nan_mask],
+                p0=param_guess,
+                bounds=param_bounds,
+                absolute_sigma=True,
+            )  #
+            energy_x = np.arange(200, 2600, 10)
+            plt.rcParams["figure.figsize"] = (12, 18)
+            plt.rcParams["font.size"] = 12
+            plt.figure()
+            for i, dt_grid in enumerate(dt_grids):
+                plt.subplot(3, 2, i + 1)
+                v_min = np.nanmin(np.abs(dt_grid))
+                if v_min == 0:
+                    for j in range(10):
+                        v_min = np.nanpercentile(np.abs(dt_grid), j + 1)
+                        if v_min > 0.1:
+                            break
+                plt.imshow(
+                    dt_grid,
+                    norm=LogNorm(vmin=v_min, vmax=np.nanpercentile(dt_grid, 98)),
+                    cmap="viridis",
+                )
+
+                plt.xticks(xs[0], xs[1])
+                plt.yticks(ys[0], ys[1])
+
+                plt.xlabel(f"{keys[1]} (us)")
+                plt.ylabel(f"{keys[0]} (us)")
+                plt.title(f"{peak_energies[i]:.1f} kev")
+                plt.xticks(rotation=45)
+                cbar = plt.colorbar()
+                cbar.set_label("FWHM (keV)")
+            plt.tight_layout()
+            plt.suptitle(f"{detector}-{e_param}-{param}")
+            pdf.savefig()
+            plt.close()
+
+            plt.figure()
+
+            plt.imshow(
+                qbb_grid,
+                norm=LogNorm(
+                    vmin=np.nanmin(qbb_grid), vmax=np.nanpercentile(dt_grid, 98)
+                ),
+                cmap="viridis",
+            )
+            plt.xlabel(f"{keys[1]} (us)")
+            plt.ylabel(f"{keys[0]} (us)")
+            plt.title(f"Qbb")
+            plt.xticks(rotation=45)
+            cbar = plt.colorbar()
+            cbar.set_label("FWHM (keV)")
+            plt.tight_layout()
+            plt.suptitle(f"{detector}-{e_param}-{param}")
+            pdf.savefig()
+            plt.close()
+
+            fig, (ax1, ax2) = plt.subplots(2, 1, constrained_layout=True, sharex=True)
+            ax1.errorbar(peak_energies, points, yerr=err_points, fmt=" ")
+            ax1.plot(energy_x, fwhm_slope(energy_x, *fit_pars))
+            ax1.errorbar(
+                [2039], qbb_grid[ixs[0], ixs[1]], yerr=qbb_errs[ixs[0], ixs[1]], fmt=" "
+            )
+            ax1.set_ylabel("FWHM energy resolution (keV)", ha="right", y=1)
+            ax2.scatter(
+                peak_energies,
+                (points - fwhm_slope(peak_energies, *fit_pars)) / err_points,
+                lw=1,
+                c="b",
+            )
+            ax2.set_xlabel("Energy (keV)", ha="right", x=1)
+            ax2.set_ylabel("Standardised Residuals", ha="right", y=1)
+            fig.suptitle(f"{detector}-{e_param}-{param}")
+            pdf.savefig()
+            plt.close()
+
+            try:
+                alphas = qbb_alphas[ixs[0], ixs[1]][0]
+                if isinstance(save_path, str):
+                    alpha_fit = np.polynomial.polynomial.polyfit(
+                        peak_energies[2:], alpha_points[2:], deg=1
+                    )
+                    fig, (ax1, ax2) = plt.subplots(
+                        2, 1, constrained_layout=True, sharex=True
+                    )
+                    ax1.errorbar(
+                        peak_energies[:],
+                        alpha_points[:],
+                        yerr=alpha_error_points[:],
+                        linestyle=" ",
+                    )
+                    ax1.plot(
+                        peak_energies[2:],
+                        np.polynomial.polynomial.polyval(peak_energies[2:], alpha_fit),
+                    )
+                    ax1.scatter([2039], qbb_alphas[ixs[0], ixs[1]])
+                    ax1.set_ylabel("Charge Trapping Value", ha="right", y=1)
+                    ax2.scatter(
+                        peak_energies[2:],
+                        (
+                            alpha_points[2:]
+                            - np.polynomial.polynomial.polyval(
+                                peak_energies[2:], alpha_fit
+                            )
+                        )
+                        / alpha_points[2:],
+                        lw=1,
+                        c="b",
+                    )
+                    ax2.set_xlabel("Energy (keV)", ha="right", x=1)
+                    ax2.set_ylabel("Residuals (%)", ha="right", y=1)
+                    fig.suptitle(f"{detector}-{param}")
+                    pdf.savefig()
+                    plt.close()
+            except:
+                alphas = np.nan
+    else:
+        try:
+            alphas = qbb_alphas[ixs[0], ixs[1]][0]
+        except:
+            alphas = np.nan
+    return alphas, fwhm_dict, db_dict, out_grid
+
+
+def get_filter_params(
+    grids, matched_configs, peak_energies, parameters, save_path=None
+):
+    """
+    Finds best parameters for filter
+    """
+
+    full_db_dict = {}
+    full_fwhm_dict = {}
+    full_grids = {}
+
+    for param in parameters:
+        opt_dict = matched_configs[param]
+        peak_grids = grids[param]
+        ctc_params = list(peak_grids[0][0, 0].keys())
+        ctc_dict = {}
+
+        for ctc_param in ctc_params:
+            if ctc_param == "QDrift":
+                alpha, fwhm, db_dict, output_grid = get_best_vals(
+                    peak_grids, peak_energies, ctc_param, opt_dict, save_path=save_path
+                )
+                opt_name = list(opt_dict.keys())[0]
+                db_dict[opt_name].update({"alpha": alpha})
+
+            else:
+                alpha, fwhm, _, output_grid = get_best_vals(
+                    peak_grids, peak_energies, ctc_param, opt_dict, save_path=save_path
+                )
+            try:
+                full_grids[param][ctc_param] = output_grid
+            except:
+                full_grids[param] = {ctc_param: output_grid}
+            fwhm.update({"alpha": alpha})
+            ctc_dict[ctc_param] = fwhm
+        full_fwhm_dict[param] = ctc_dict
+        full_db_dict.update(db_dict)
+    return full_db_dict, full_fwhm_dict, full_grids
diff --git a/src/pygama/pargen/extract_tau.py b/src/pygama/pargen/extract_tau.py
new file mode 100644
index 000000000..0338d16c5
--- /dev/null
+++ b/src/pygama/pargen/extract_tau.py
@@ -0,0 +1,243 @@
+"""
+This module is for extracting a single pole zero constant from the decay tail
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import pathlib
+import pickle as pkl
+from collections import OrderedDict
+
+import matplotlib as mpl
+import matplotlib.pyplot as plt
+import numpy as np
+
+import pygama
+import pygama.lgdo as lgdo
+import pygama.lgdo.lh5_store as lh5
+import pygama.math.histogram as pgh
+import pygama.pargen.cuts as cts
+import pygama.pargen.dsp_optimize as opt
+import pygama.pargen.energy_optimisation as om
+
+log = logging.getLogger(__name__)
+
+
+def run_tau(
+    raw_file: list[str],
+    config: dict,
+    lh5_path: str,
+    n_events: int = 10000,
+    threshold: int = 5000,
+) -> lgdo.Table:
+    sto = lh5.LH5Store()
+    df = lh5.load_dfs(raw_file, ["daqenergy", "timestamp"], lh5_path)
+
+    pulser_props = cts.find_pulser_properties(df, energy="daqenergy")
+    if len(pulser_props) > 0:
+        out_df = cts.tag_pulsers(df, pulser_props, window=0.001)
+        ids = ~(out_df.isPulser == 1)
+        log.debug(f"pulser found: {pulser_props}")
+    else:
+        log.debug("no_pulser")
+        ids = np.ones(len(df.daqenergy.values), dtype=bool)
+
+    cuts = np.where((df.daqenergy.values > threshold) & (ids))[0]
+
+    waveforms = sto.read_object(
+        f"{lh5_path}/waveform", raw_file, idx=cuts, n_rows=n_events
+    )[0]
+    baseline = sto.read_object(
+        f"{lh5_path}/baseline", raw_file, idx=cuts, n_rows=n_events
+    )[0]
+    tb_data = lh5.Table(col_dict={"waveform": waveforms, "baseline": baseline})
+    return tb_data
+
+
+def get_decay_constant(slopes: np.array, wfs: np.array, plot_path: str = None) -> dict:
+
+    """
+    Finds the decay constant from the modal value of the tail slope after cuts
+    and saves it to the specified json.
+
+    Parameters
+    ----------
+    slopes : array
+        tail slope array
+
+    dict_file : str
+        path to json file to save decay constant value to.
+        It will be saved as a dictionary of form {'pz': {'tau': decay_constant}}
+
+    Returns
+    -------
+    tau_dict : dict
+    """
+    tau_dict = {}
+
+    pz = tau_dict.get("pz")
+
+    counts, bins, var = pgh.get_hist(slopes, bins=50000, range=(-0.01, 0))
+    bin_centres = pgh.get_bin_centers(bins)
+    tau = round(-1 / (bin_centres[np.argmax(counts)]), 1)
+
+    tau_dict["pz"] = {"tau": tau}
+    if plot_path is None:
+        return tau_dict
+    else:
+        out_plot_dict = {}
+        pathlib.Path(os.path.dirname(plot_path)).mkdir(parents=True, exist_ok=True)
+        plt.rcParams["figure.figsize"] = (10, 6)
+        plt.rcParams["font.size"] = 8
+        fig, ax = plt.subplots()
+        bins = 10000  # change if needed
+        counts, bins, bars = ax.hist(slopes, bins=bins, histtype="step")
+        plot_max = np.argmax(counts)
+        in_min = plot_max - 10
+        if in_min < 0:
+            in_min = 0
+        in_max = plot_max + 11
+        if in_max >= len(bins):
+            in_min = len(bins) - 1
+        plt.xlabel("Slope")
+        plt.ylabel("Counts")
+        plt.yscale("log")
+        axins = ax.inset_axes([0.5, 0.45, 0.47, 0.47])
+        axins.hist(
+            slopes[(slopes > bins[in_min]) & (slopes < bins[in_max])],
+            bins=200,
+            histtype="step",
+        )
+        axins.set_xlim(bins[in_min], bins[in_max])
+        labels = ax.get_xticklabels()
+        ax.set_xticklabels(labels=labels, rotation=45)
+        out_plot_dict["slope"] = fig
+        plt.close()
+
+        wf_idxs = np.random.choice(len(wfs), 100)
+        fig2 = plt.figure()
+        for wf_idx in wf_idxs:
+            plt.plot(np.arange(0, len(wfs[wf_idx]), 1), wfs[wf_idx])
+        plt.xlabel("Samples")
+        plt.ylabel("ADU")
+        out_plot_dict["waveforms"] = fig2
+        with open(plot_path, "wb") as f:
+            pkl.dump(out_plot_dict, f)
+        plt.close()
+        return tau_dict
+
+
+def fom_dpz(tb_data, verbosity=0, rand_arg=None):
+
+    std = tb_data["pz_std"].nda
+    counts, start_bins, var = pgh.get_hist(std, 10**5)
+    max_idx = np.argmax(counts)
+    mu = start_bins[max_idx]
+    try:
+        pars, cov = pgf.gauss_mode_width_max(
+            counts,
+            start_bins,
+            mode_guess=mu,
+            n_bins=10,
+            cost_func="Least Squares",
+            inflate_errors=False,
+            gof_method="var",
+        )
+
+        mu = pars[0]
+
+    except:
+        mu = start_bins[max_idx]
+
+    return {"y_val": np.abs(mu)}
+
+
+def get_dpz_consts(grid_out, opt_dict):
+    std_grid = np.ndarray(shape=grid_out.shape)
+    for i in range(grid_out.shape[0]):
+        for j in range(grid_out.shape[1]):
+            std_grid[i, j] = grid_out[i, j]["y_val"]
+    min_val = np.amin(std_grid)
+    min_point = np.where(std_grid == np.amin(std_grid))
+
+    opt_name = list(opt_dict.keys())[0]
+    keys = list(opt_dict[opt_name].keys())
+    param_list = []
+    shape = []
+    db_dict = {}
+    for key in keys:
+        param_dict = opt_dict[opt_name][key]
+        grid_axis = np.arange(
+            param_dict["start"], param_dict["end"], param_dict["spacing"]
+        )
+        unit = param_dict.get("unit")
+        param_list.append(grid_axis)
+        shape.append(len(grid_axis))
+    for i, key in enumerate(keys):
+        unit = opt_dict[opt_name][key].get("unit")
+
+        if unit is not None:
+            try:
+                db_dict[opt_name].update(
+                    {key: f"{param_list[i][min_point[i]][0]}*{unit}"}
+                )
+            except:
+                db_dict[opt_name] = {key: f"{param_list[i][min_point[i]][0]}*{unit}"}
+        else:
+            try:
+                db_dict[opt_name].update({key: f"{param_list[i][min_point[i]][0]}"})
+            except:
+                db_dict[opt_name] = {key: f"{param_list[i][min_point[i]][0]}"}
+    return db_dict
+
+
+def dsp_preprocess_decay_const(
+    raw_files: list[str],
+    dsp_config: dict,
+    lh5_path: str,
+    double_pz: bool = False,
+    plot_path: str = None,
+    opt_dict: dict = None,
+    threshold: int = 5000,
+    cut_parameters: dict = {"bl_mean": 4, "bl_std": 4, "bl_slope": 4},
+) -> dict:
+    """
+    This function calculates the pole zero constant for the input data
+
+    Parameters
+    ----------
+    f_raw : str
+        The raw file to run the macro on
+    dsp_config: str
+        Path to the dsp config file, this is a stripped down version which just includes cuts and slope of decay tail
+    channel:  str
+        Name of channel to process, should be name of lh5 group in raw files
+
+    Returns
+    -------
+    tau_dict : dict
+    """
+
+    tb_data = run_tau(raw_files, dsp_config, lh5_path, threshold=threshold)
+    tb_out = opt.run_one_dsp(tb_data, dsp_config)
+    log.debug("Processed Data")
+    cut_dict = cts.generate_cuts(tb_out, parameters=cut_parameters)
+    log.debug("Generated Cuts:", cut_dict)
+    idxs = cts.get_cut_indexes(tb_out, cut_dict)
+    log.debug("Applied cuts")
+    slopes = tb_out["tail_slope"].nda
+    wfs = tb_out["wf_blsub"]["values"].nda
+    log.debug("Calculating pz constant")
+    tau_dict = get_decay_constant(slopes[idxs], wfs[idxs], plot_path=plot_path)
+    if double_pz == True:
+        log.debug("Calculating double pz constants")
+        pspace = om.set_par_space(opt_dict)
+        grid_out = opt.run_grid(
+            tb_data, dsp_config, pspace, fom_dpz, tau_dict, fom_kwargs=None
+        )
+        out_dict = get_dpz_consts(grid_out, opt_dict)
+        tau_dict["pz"].update(out_dict["pz"])
+    return tau_dict
diff --git a/src/pygama/pargen/mse_psd.py b/src/pygama/pargen/mse_psd.py
index 1599159f1..cc15d9413 100644
--- a/src/pygama/pargen/mse_psd.py
+++ b/src/pygama/pargen/mse_psd.py
@@ -16,23 +16,24 @@
 
 
 def get_avse_cut(e_cal, current, plotFigure=None):
-    #DEP range and nearby BG for BG subtraction
-    dep_idxs = (e_cal > 1585) & (e_cal < 1595
-                                )  #(asymmetric around 1592 to account for CT)
+    # DEP range and nearby BG for BG subtraction
+    dep_idxs = (e_cal > 1585) & (
+        e_cal < 1595
+    )  # (asymmetric around 1592 to account for CT)
     bg_idxs = (e_cal > 1560) & (e_cal < 1570)
 
-    #SEP range and nearby BG for BG subtraction
+    # SEP range and nearby BG for BG subtraction
     sep_idxs = (e_cal > 2090) & (e_cal < 2115)
     bg_sep_idxs = (e_cal > 2060) & (e_cal < 2080)
 
-    #Bin the data in 2D, 25 keV bins
+    # Bin the data in 2D, 25 keV bins
     # xedges = np.arange(200,2300,25)
     # e_cent = get_bin_centers(xedges)
     # y_max = np.zeros_like(e_cent)
     # plt.ion()
     # plt.figure()
 
-    compton_shoulder = 2614 * (1 - 1. / (1 + 2 * 2614 / 511))
+    compton_shoulder = 2614 * (1 - 1.0 / (1 + 2 * 2614 / 511))
 
     # peaks = [238,510,583,727,860,1078,1512,1592,1806,compton_shoulder, 2614]
     # peaks = np.array(peaks)
@@ -77,13 +78,13 @@ def get_avse_cut(e_cal, current, plotFigure=None):
         # inp = input("q to quit")
         # if inp == "q": exit()
 
-    #quadratic fit
-    #first fit a line:
+    # quadratic fit
+    # first fit a line:
     p_lin = np.polyfit(e_cent, y_max, 1)
-    #find residuals:
+    # find residuals:
     resid = y_max - np.poly1d(p_lin)(e_cent)
     resid_std = np.std(resid)
-    #a really big residual is a pulser peak:
+    # a really big residual is a pulser peak:
     puls_idx = resid > resid_std
 
     e_cent_cut = e_cent[~puls_idx]
@@ -94,18 +95,18 @@ def get_avse_cut(e_cal, current, plotFigure=None):
 
     a_adjusted = current - np.poly1d(avse_quad)(e_cal)
 
-    #Look at DEP, bg subtract the AvsE spectrum
+    # Look at DEP, bg subtract the AvsE spectrum
     h_dep, bins = np.histogram(a_adjusted[dep_idxs], bins=5000)
     h_bg, bins = np.histogram(a_adjusted[bg_idxs], bins=bins)
     bin_centers = get_bin_centers(bins)
     h_bgs = h_dep - h_bg
-    #fit AvsE peak to gaussian to get the 90% cut
+    # fit AvsE peak to gaussian to get the 90% cut
     p0 = get_gaussian_guess(h_bgs, bin_centers)
     p = fit_binned(nb_gauss, h_bgs, bin_centers, p0)
     fit = nb_gauss(bin_centers, *p)
 
     ae_mean, ae_std = p[0], p[1]
-    ae_cut = p[0] - 1.28 * p[1]  #cuts at 10% of CDF
+    ae_cut = p[0] - 1.28 * p[1]  # cuts at 10% of CDF
 
     avse2, avse1, avse0 = avse_quad[:]
     avse_cut = ae_cut
@@ -145,12 +146,13 @@ def get_avse_cut(e_cal, current, plotFigure=None):
         ax_sep = plt.subplot(grid[1, 0])
         ax_ae = plt.subplot(grid[:, 1])
 
-        #adjust a over e for mean
+        # adjust a over e for mean
         a_over_e = a_adjusted
         ae_cut_mod = ae_cut
 
         h_dep, bins = np.histogram(
-            a_over_e[dep_idxs], bins=np.linspace(-12 * ae_std, 8 * ae_std, 100))
+            a_over_e[dep_idxs], bins=np.linspace(-12 * ae_std, 8 * ae_std, 100)
+        )
         h_bg, bins = np.histogram(a_over_e[bg_idxs], bins=bins)
         bin_centers = bins[:-1] + 0.5 * (bins[1] - bins[0])
         h_bgs = h_dep - h_bg
@@ -163,17 +165,15 @@ def get_avse_cut(e_cal, current, plotFigure=None):
         # ax_ae.plot(bin_centers, h_bgs_sep/ np.sum(h_bgs), ls="steps-mid", color = "g", label = "SEP (BG subtracted)")
 
         ax_ae.plot(
-            bin_centers,
-            h_bgs,
-            ls="steps-mid",
-            color="b",
-            label="DEP (BG subtracted)")
+            bin_centers, h_bgs, ls="steps-mid", color="b", label="DEP (BG subtracted)"
+        )
         ax_ae.plot(
             bin_centers,
             h_bgs_sep,
             ls="steps-mid",
             color="g",
-            label="SEP (BG subtracted)")
+            label="SEP (BG subtracted)",
+        )
         ax_ae.axvline(ae_cut_mod, color="r", ls=":")
         ax_ae.set_xlim(-12 * ae_std, 8 * ae_std)
         ax_ae.legend(loc=2)
@@ -187,7 +187,7 @@ def get_avse_cut(e_cal, current, plotFigure=None):
         e_cal_aepass = e_cal[ae_cut_idxs]
 
         pad = 50
-        bin_size = 0.2  #keV
+        bin_size = 0.2  # keV
         bins = np.arange(1592 - pad, 1592 + pad + bin_size, bin_size)
 
         ax_dep.hist(
@@ -195,14 +195,15 @@ def get_avse_cut(e_cal, current, plotFigure=None):
             histtype="step",
             color="k",
             label="DEP",
-            bins=bins)
+            bins=bins,
+        )
         ax_dep.hist(
-            e_cal_aepass[(e_cal_aepass > 1592 - pad) &
-                         (e_cal_aepass < 1592 + pad)],
+            e_cal_aepass[(e_cal_aepass > 1592 - pad) & (e_cal_aepass < 1592 + pad)],
             histtype="step",
             color="b",
             label="After Cut",
-            bins=bins)
+            bins=bins,
+        )
         ax_dep.legend(loc=2)
         ax_dep.set_xlabel("Energy [keV]")
 
@@ -212,14 +213,15 @@ def get_avse_cut(e_cal, current, plotFigure=None):
             histtype="step",
             color="k",
             label="SEP",
-            bins=bins)
+            bins=bins,
+        )
         ax_sep.hist(
-            e_cal_aepass[(e_cal_aepass > 2103 - pad) &
-                         (e_cal_aepass < 2103 + pad)],
+            e_cal_aepass[(e_cal_aepass > 2103 - pad) & (e_cal_aepass < 2103 + pad)],
             histtype="step",
             color="b",
             label="After Cut",
-            bins=bins)
+            bins=bins,
+        )
         ax_sep.legend(loc=2)
         ax_sep.set_xlabel("Energy [keV]")
 
@@ -227,14 +229,15 @@ def get_avse_cut(e_cal, current, plotFigure=None):
 
 
 def get_ae_cut(e_cal, current, plotFigure=None):
-    #try to get a rough A/E cut
+    # try to get a rough A/E cut
 
-    #DEP range and nearby BG for BG subtraction
-    dep_idxs = (e_cal > 1585) & (e_cal < 1595
-                                )  #(asymmetric around 1592 to account for CT)
+    # DEP range and nearby BG for BG subtraction
+    dep_idxs = (e_cal > 1585) & (
+        e_cal < 1595
+    )  # (asymmetric around 1592 to account for CT)
     bg_idxs = (e_cal > 1560) & (e_cal < 1570)
 
-    #SEP range and nearby BG for BG subtraction
+    # SEP range and nearby BG for BG subtraction
     sep_idxs = (e_cal > 2090) & (e_cal < 2115)
     bg_sep_idxs = (e_cal > 2060) & (e_cal < 2080)
 
@@ -254,7 +257,7 @@ def get_ae_cut(e_cal, current, plotFigure=None):
     fit = nb_gauss(bin_centers, *p)
 
     ae_mean, ae_std = p[0], p[1]
-    ae_cut = p[0] - 1.28 * p[1]  #cuts at 10% of CDF
+    ae_cut = p[0] - 1.28 * p[1]  # cuts at 10% of CDF
 
     if plotFigure is not None:
         ####
@@ -268,13 +271,12 @@ def get_ae_cut(e_cal, current, plotFigure=None):
         ax_sep = plt.subplot(grid[1, 0])
         ax_ae = plt.subplot(grid[:, 1])
 
-        #adjust a over e for mean
+        # adjust a over e for mean
 
         a_over_e = (a_over_e - ae_mean) / ae_std
         ae_cut_mod = (ae_cut - ae_mean) / ae_std
 
-        h_dep, bins = np.histogram(
-            a_over_e[dep_idxs], bins=np.linspace(-8, 6, 50))
+        h_dep, bins = np.histogram(a_over_e[dep_idxs], bins=np.linspace(-8, 6, 50))
         h_bg, bins = np.histogram(a_over_e[bg_idxs], bins=bins)
         bin_centers = bins[:-1] + 0.5 * (bins[1] - bins[0])
         h_bgs = h_dep - h_bg
@@ -288,13 +290,15 @@ def get_ae_cut(e_cal, current, plotFigure=None):
             h_bgs / np.sum(h_bgs),
             ls="steps-mid",
             color="b",
-            label="DEP (BG subtracted)")
+            label="DEP (BG subtracted)",
+        )
         ax_ae.plot(
             bin_centers,
             h_bgs_sep / np.sum(h_bgs),
             ls="steps-mid",
             color="g",
-            label="SEP (BG subtracted)")
+            label="SEP (BG subtracted)",
+        )
         # plt.plot(bin_centers, fit, color="g")
         ax_ae.axvline(ae_cut_mod, color="r", ls=":")
         ax_ae.set_xlim(-8, 5)
@@ -316,14 +320,15 @@ def get_ae_cut(e_cal, current, plotFigure=None):
             histtype="step",
             color="k",
             label="DEP",
-            bins=bins)
+            bins=bins,
+        )
         ax_dep.hist(
-            e_cal_aepass[(e_cal_aepass > 1592 - pad) &
-                         (e_cal_aepass < 1592 + pad)],
+            e_cal_aepass[(e_cal_aepass > 1592 - pad) & (e_cal_aepass < 1592 + pad)],
             histtype="step",
             color="b",
             label="After Cut",
-            bins=bins)
+            bins=bins,
+        )
         ax_dep.legend(loc=2)
         ax_dep.set_xlabel("Energy [keV]")
 
@@ -333,14 +338,15 @@ def get_ae_cut(e_cal, current, plotFigure=None):
             histtype="step",
             color="k",
             label="SEP",
-            bins=bins)
+            bins=bins,
+        )
         ax_sep.hist(
-            e_cal_aepass[(e_cal_aepass > 2103 - pad) &
-                         (e_cal_aepass < 2103 + pad)],
+            e_cal_aepass[(e_cal_aepass > 2103 - pad) & (e_cal_aepass < 2103 + pad)],
             histtype="step",
             color="b",
             label="After Cut",
-            bins=bins)
+            bins=bins,
+        )
         ax_sep.legend(loc=2)
         ax_sep.set_xlabel("Energy [keV]")
 
diff --git a/src/pygama/raw/build_raw.py b/src/pygama/raw/build_raw.py
index ed87e1ecf..16e3873f6 100644
--- a/src/pygama/raw/build_raw.py
+++ b/src/pygama/raw/build_raw.py
@@ -4,7 +4,6 @@
 import json
 import logging
 import os
-import sys
 import time
 
 import numpy as np
@@ -137,19 +136,16 @@ def build_raw(
     log.info(f"buffer size: {buffer_size}")
     if n_max < np.inf:
         log.info(f"maximum number of events: {n_max}")
-    if log.level <= logging.INFO:
+    if log.getEffectiveLevel() >= logging.INFO:
         if n_max < np.inf:
-            progress_bar = tqdm(
-                desc="Processing", total=n_max, delay=2, unit="rows", file=sys.stdout
-            )
+            progress_bar = tqdm(desc="Decoding", total=n_max, delay=2, unit=" rows")
         else:
             progress_bar = tqdm(
-                desc="Processing",
+                desc="Decoding",
                 total=in_stream_size,
                 delay=2,
-                unit="B",
+                unit=" B",
                 unit_scale=True,
-                file=sys.stdout,
             )
 
     # start a timer and a byte counter
@@ -171,7 +167,7 @@ def build_raw(
         raise NotImplementedError("unknown input stream type {in_stream_type}")
 
     # initialize the stream and read header. Also initializes rb_lib
-    if log.level <= logging.INFO:
+    if log.getEffectiveLevel() >= logging.INFO:
         progress_bar.update(0)
 
     out_stream = out_spec if isinstance(out_spec, str) else ""
@@ -183,7 +179,7 @@ def build_raw(
         out_stream=out_stream,
     )
     rb_lib = streamer.rb_lib
-    if log.level <= logging.INFO and n_max == np.inf:
+    if log.getEffectiveLevel() >= logging.INFO and n_max == np.inf:
         progress_bar.update(streamer.n_bytes_read)
 
     # rb_lib should now be fully initialized. Check if files need to be
@@ -215,7 +211,7 @@ def build_raw(
     n_bytes_last = streamer.n_bytes_read
     while True:
         chunk_list = streamer.read_chunk()
-        if log.level <= logging.INFO and n_max == np.inf:
+        if log.getEffectiveLevel() >= logging.INFO and n_max == np.inf:
             progress_bar.update(streamer.n_bytes_read - n_bytes_last)
             n_bytes_last = streamer.n_bytes_read
         if len(chunk_list) == 0:
@@ -226,7 +222,7 @@ def build_raw(
                 rb.loc = n_max
             n_max -= rb.loc
             n_read += rb.loc
-        if log.level <= logging.INFO and n_max < np.inf:
+        if log.getEffectiveLevel() >= logging.INFO and n_max < np.inf:
             progress_bar.update(n_read)
         write_to_lh5_and_clear(chunk_list, lh5_store)
         if n_max <= 0:
diff --git a/src/pygama/raw/data_decoder.py b/src/pygama/raw/data_decoder.py
index 72462712a..7cb98e945 100644
--- a/src/pygama/raw/data_decoder.py
+++ b/src/pygama/raw/data_decoder.py
@@ -3,7 +3,6 @@
 """
 from __future__ import annotations
 
-from abc import ABC
 from typing import Union
 
 import numpy as np
@@ -15,7 +14,7 @@
 LGDO = Union[lgdo.Scalar, lgdo.Struct, lgdo.Array, lgdo.VectorOfVectors]
 
 
-class DataDecoder(ABC):
+class DataDecoder:
     r"""Decodes packets from a data stream.
 
     Most decoders will repeatedly decode the same set of values from each
diff --git a/src/pygama/raw/data_streamer.py b/src/pygama/raw/data_streamer.py
index 8f271ec0d..ae9a91f06 100644
--- a/src/pygama/raw/data_streamer.py
+++ b/src/pygama/raw/data_streamer.py
@@ -4,7 +4,7 @@
 from __future__ import annotations
 
 import logging
-from abc import ABC
+from abc import ABC, abstractmethod
 
 from .raw_buffer import RawBuffer, RawBufferLibrary, RawBufferList
 
@@ -33,6 +33,7 @@ def __init__(self) -> None:
         self.any_full = False
         self.packet_id = 0
 
+    @abstractmethod
     def open_stream(
         self,
         stream_name: str,
@@ -146,6 +147,7 @@ def open_stream(
             if dec_name not in dec_names:
                 log.warning(f"no decoder named '{dec_name}' requested by rb_lib")
 
+    @abstractmethod
     def close_stream(self) -> None:
         """Close this data stream.
 
@@ -154,6 +156,7 @@ def close_stream(self) -> None:
         """
         pass
 
+    @abstractmethod
     def read_packet(self) -> bool:
         """Reads a single packet's worth of data in to the
         :class:`.RawBufferLibrary`.
@@ -252,6 +255,7 @@ def read_chunk(
                     list_of_rbs.append(rb)
         return list_of_rbs
 
+    @abstractmethod
     def get_decoder_list(self) -> list:
         """Returns a list of decoder objects for this data stream.
 
diff --git a/src/pygama/raw/orca/__init__.py b/src/pygama/raw/orca/__init__.py
index 483122c53..c99de5bd1 100644
--- a/src/pygama/raw/orca/__init__.py
+++ b/src/pygama/raw/orca/__init__.py
@@ -1,5 +1,5 @@
 """
 This subpackage overloads decoding utilities defined in :mod:`pygama.raw` to
-read files produced by the `ORCA <http://orca.physics.unc.edu/orca>`
+read files produced by the `ORCA <http://orca.physics.unc.edu/orca>`_
 acquisition system.
 """
diff --git a/src/pygama/raw/orca/orca_base.py b/src/pygama/raw/orca/orca_base.py
index e10c4759b..8446f5073 100644
--- a/src/pygama/raw/orca/orca_base.py
+++ b/src/pygama/raw/orca/orca_base.py
@@ -22,18 +22,36 @@ def set_header(self, header: OrcaHeader) -> None:
         self.header = header
 
 
-# define a standard hash for crate, card, channel <--> integer
 def get_ccc(crate: int, card: int, channel: int) -> int:
+    """Define a standard hash for (crate, card, channel) to integer."""
     return (crate << 9) + ((card & 0x1F) << 4) + (channel & 0xF)
 
 
 def get_crate(ccc: int) -> int:
+    """Get crate number back from hash value.
+
+    See Also
+    --------
+    .get_ccc
+    """
     return ccc >> 9
 
 
 def get_card(ccc: int) -> int:
+    """Get card number back from hash value.
+
+    See Also
+    --------
+    .get_ccc
+    """
     return (ccc >> 4) & 0x1F
 
 
 def get_channel(ccc: int) -> int:
+    """Get channel number back from hash value.
+
+    See Also
+    --------
+    .get_ccc
+    """
     return ccc & 0xF
diff --git a/src/pygama/raw/orca/orca_digitizers.py b/src/pygama/raw/orca/orca_digitizers.py
index e355a0738..c933459b6 100644
--- a/src/pygama/raw/orca/orca_digitizers.py
+++ b/src/pygama/raw/orca/orca_digitizers.py
@@ -1,23 +1,25 @@
 import copy
 import logging
 import sys
+from typing import Any
 
 import numpy as np
 
-from .orca_base import OrcaDecoder, get_ccc
+from pygama.raw.orca.orca_base import OrcaDecoder, get_ccc
+from pygama.raw.orca.orca_header import OrcaHeader
+from pygama.raw.orca.orca_packet import OrcaPacket
+from pygama.raw.raw_buffer import RawBufferLibrary
 
 log = logging.getLogger(__name__)
 
 
 class ORSIS3302DecoderForEnergy(OrcaDecoder):
-    """
-    Decoder for Struck 3302 digitizer data written by ORCA
+    """Decoder for `Struck SIS3302 <https://www.struck.de/sis3302.htm>`_ digitizer
+    data written by ORCA.
     """
 
-    def __init__(self, header=None, **kwargs):
-        """
-        DOCME
-        """
+    def __init__(self, header: OrcaHeader = None, **kwargs) -> None:
+
         self.decoded_values_template = {
             "packet_id": {
                 "dtype": "uint32",
@@ -55,7 +57,7 @@ def __init__(self, header=None, **kwargs):
         super().__init__(header=header, **kwargs)
         self.skipped_channels = {}
 
-    def set_header(self, header):
+    def set_header(self, header: OrcaHeader) -> None:
         self.header = header
 
         obj_info_dict = header.get_object_info("ORSIS3302Model")
@@ -87,19 +89,17 @@ def set_header(self, header):
                         int(channel / 2)
                     ]
                     if trace_length <= 0 or trace_length > 2**16:
-                        raise RuntimeError(
-                            f"SIS3316ORCADecoder Error: invalid trace_length {trace_length}"
-                        )
+                        raise RuntimeError(f"invalid trace_length {trace_length}")
                         sys.exit()
                     self.decoded_values[ccc]["waveform"]["wf_len"] = trace_length
 
-    def get_key_list(self):
+    def get_key_list(self) -> list[str]:
         key_list = []
         for key in self.decoded_values.keys():
             key_list += [key]
         return key_list
 
-    def get_decoded_values(self, key=None):
+    def get_decoded_values(self, key: int = None) -> dict[str, Any]:
         if key is None:
             dec_vals_list = self.decoded_values.values()
             if len(dec_vals_list) >= 0:
@@ -109,8 +109,9 @@ def get_decoded_values(self, key=None):
             return self.decoded_values[key]
         raise KeyError(f"no decoded values for key {key}")
 
-    def decode_packet(self, packet, packet_id, rbl):
-        """decode the orca struck 3302 packet"""
+    def decode_packet(
+        self, packet: OrcaPacket, packet_id: int, rbl: RawBufferLibrary
+    ) -> bool:
         evt_rbkd = rbl.get_keyed_dict()
 
         # read the crate/card/channel first
@@ -134,16 +135,12 @@ def decode_packet(self, packet, packet_id, rbl):
         tbl["packet_id"].nda[ii] = packet_id
 
         # read the rest of the record
-        # n_lost_msb = (packet[1] >> 25) & 0x7F
-        # n_lost_lsb = (packet[1] >> 2) & 0x7F
-        # n_lost_records = (n_lost_msb << 7) + n_lost_lsb
         tbl["crate"].nda[ii] = crate
         tbl["card"].nda[ii] = card
         tbl["channel"].nda[ii] = channel
         buffer_wrap = packet[1] & 0x1
         wf_length32 = packet[2]
         ene_wf_length32 = packet[3]
-        # evt_header_id = packet[4] & 0xFF
         tbl["timestamp"].nda[ii] = packet[5] + (
             (packet[4] & 0xFFFF0000) << 16
         )  # might need to convert to uint64
@@ -152,10 +149,9 @@ def decode_packet(self, packet, packet_id, rbl):
         # get the footer
         tbl["energy"].nda[ii] = packet[-4]
         tbl["energy_first"].nda[ii] = packet[-3]
-        # extra_flags = packet[-2]
 
-        # interpret the raw event data into numpy array of 16 bit ints
-        # does not copy data. p16 is read-only
+        # Interpret the raw event data into numpy array of 16 bit ints.
+        # Does not copy data. p16 is read-only
         p16 = np.frombuffer(packet, dtype=np.uint16)
 
         # compute expected and actual array dimensions
@@ -179,8 +175,6 @@ def decode_packet(self, packet, packet_id, rbl):
         # splitting waveform indices into two chunks (all referring to the 16 bit array)
         i_wf_start = header_length16
         i_wf_stop = i_wf_start + wf_length16
-        # i_ene_start = i_wf_stop + 1
-        # i_ene_stop = i_ene_start + ene_wf_length16
         if buffer_wrap:
             # start somewhere in the middle of the record
             i_start_1 = packet[7] + header_length16 + 1
@@ -210,3 +204,348 @@ def decode_packet(self, packet, packet_id, rbl):
 
         evt_rbkd[ccc].loc += 1
         return evt_rbkd[ccc].is_full()
+
+
+class ORSIS3316WaveformDecoder(OrcaDecoder):
+    """Decoder for `Struck SIS3316 <https://www.struck.de/sis3316.html>`_
+    digitizer data written by ORCA."""
+
+    def __init__(self, header: OrcaHeader = None, **kwargs) -> None:
+
+        # store an entry for every event
+        self.decoded_values_template = {
+            "packet_id": {
+                "dtype": "uint32",
+            },
+            "energy": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "peakHighValue": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "peakHighIndex": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "accSum1": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "information": {
+                "dtype": "uint32",
+            },
+            "accSum2": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "accSum3": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "accSum4": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "accSum5": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "accSum6": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "accSum7": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "accSum8": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "mawMax": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "mawBefore": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "mawAfter": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "startEnergy": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "maxEnergy": {
+                "dtype": "uint32",
+                "units": "adc",
+            },
+            "energy_first": {
+                "dtype": "uint32",
+            },
+            "timestamp": {
+                "dtype": "uint64",
+                "units": "clock_ticks",
+            },
+            "crate": {
+                "dtype": "uint8",
+            },
+            "card": {
+                "dtype": "uint8",
+            },
+            "channel": {
+                "dtype": "uint8",
+            },
+            # waveform data
+            "waveform": {
+                "dtype": "uint16",
+                "datatype": "waveform",
+                "wf_len": 65532,  # max value. override this before initializing buffers to save RAM
+                "dt": 8,  # override if a different clock rate is used
+                "dt_units": "ns",
+                "t0_units": "ns",
+            },
+        }
+
+        self.decoded_values = {}
+        self.skipped_channels = {}
+        super().__init__(
+            header=header, **kwargs
+        )  # also initializes the garbage df (whatever that means...)
+
+    def set_header(self, header: OrcaHeader) -> None:
+        self.header = header
+
+        for card_dict in self.header["ObjectInfo"]["Crates"][0]["Cards"]:
+            if card_dict["Class Name"] == "ORSIS3316Model":
+                channel_list = [int(d) for d in str(bin(card_dict["enabledMask"]))[2:]]
+                card = card_dict["Card"]
+                crate = 0
+                for i in range(0, len(channel_list)):
+                    if int(channel_list[i]) == 1:
+                        channel = i
+                        ccc = get_ccc(crate, card, channel)
+                        trace_length = card_dict["rawDataBufferLen"]
+                        self.decoded_values[ccc] = copy.deepcopy(
+                            self.decoded_values_template
+                        )
+
+                        if trace_length <= 0 or trace_length > 2**16:
+                            raise RuntimeError(
+                                "invalid trace_length: ",
+                                trace_length,
+                            )
+
+                        self.decoded_values[ccc]["waveform"]["wf_len"] = trace_length
+                    else:
+                        continue
+
+    def get_key_list(self) -> list[int]:
+        key_list = []
+        for key in self.decoded_values.keys():
+            key_list += [key]
+        return key_list
+
+    def get_decoded_values(self, key: int = None) -> dict[str, Any]:
+        if key is None:
+            dec_vals_list = self.decoded_values.values()
+            if len(dec_vals_list) == 0:
+                raise RuntimeError("decoded_values not built yet!")
+
+            return dec_vals_list  # Get first thing we find
+        else:
+            dec_vals_list = self.decoded_values[key]
+            return dec_vals_list
+        raise RuntimeError("no decoded values for key", key)
+
+    def decode_packet(
+        self, packet: OrcaPacket, packet_id: int, rbl: RawBufferLibrary
+    ) -> bool:
+        """Decode the ORCA SIS3316 ADC packet.
+
+        The packet is formatted as:
+
+        .. code-block:: text
+
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx
+            ^^^^ ^^^^ ^^^^ ^^----------------------- Data ID (from header)
+            -----------------^^ ^^^^ ^^^^ ^^^^ ^^^^- length
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx
+            --------^-^^^--------------------------- Crate number
+            -------------^-^^^^--------------------- Card number
+            --------------------^^^^ ^^^^----------- Chan number
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  Num Events in this packet
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  Num longs in each record
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  Num of Records that were in the FIFO
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  Num of longs in data header -- can get from the raw data also
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  Spare
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  Spare
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  Spare
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  Spare
+
+        Then, data events follow with format described in below (note the format bits):
+
+        .. code-block:: text
+
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx
+            -----------------------------------^^^^- Format bits (from header)
+            --------------------^^^^ ^^^^ ^^^^------ Channel ID
+            ^^^^ ^^^^ ^^^^ ^^^^--------------------- Timestamp[47:32]
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx  Timestamp[31:0]
+            if Format bit 0 = 1 add
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx
+            --------------------^^^^ ^^^^ ^^^^ ^^^^- Peakhigh value
+            ^^^^ ^^^^ ^^^^ ^^^^--------------------- Index of Peakhigh value
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx
+            ----------^^^^ ^^^^ ^^^^ ^^^^ ^^^^ ^^^^- Accumulator sum of Gate 1 [23:0]
+            ^^^^ ^^^^------------------------------- Information byte
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx Accumulator sum of Gate 2 [27:0]
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx Accumulator sum of Gate 3 [27:0]
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx Accumulator sum of Gate 4 [27:0]
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx Accumulator sum of Gate 5 [27:0]
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx Accumulator sum of Gate 6 [27:0]
+            If Format bit 1 = 1 add
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx Accumulator sum of Gate 7 [27:0]
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx Accumulator sum of Gate 8 [27:0]
+            If Format bit 2 = 1 add
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx MAW Maximum value [27:0]
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx MAW Value before Trigger [27:0]
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx MAW Value after/with Trigger [27:0]
+            If Format bit 3 = 1 add
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx Start Energy Value (in Trigger Gate)
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx Max Energy Value (in Trigger Gate)
+            Regardless of format bit
+            xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx
+            -------^^ ^^^^ ^^^^ ^^^^ ^^^^ ^^^^ ^^^^- number of raw samples divided by 2
+            ------^--------------------------------- status Flag
+            -----^---------------------------------- MAW Test Flag
+            Followed by N ADC Raw Samples (2 Samples per 32 bit word)
+            Followed by MAW Test data
+        """
+
+        evt_rbkd = rbl.get_keyed_dict()
+
+        evt_data_16 = np.frombuffer(packet, dtype=np.uint16)
+
+        # First grab the crate, card, and channel to calculate ccc
+        crate = (packet[1] >> 21) & 0xF
+        card = (packet[1] >> 16) & 0x1F
+        channel = (packet[1] >> 8) & 0xFF
+        ccc = get_ccc(crate, card, channel)
+
+        # Check if this ccc should be recorded.
+        if ccc not in evt_rbkd:
+            if ccc not in self.skipped_channels:
+                self.skipped_channels[ccc] = 0
+                log.debug(f"Skipping channel: {ccc}")
+                log.debug(f"evt_rbkd: {evt_rbkd.keys()}")
+            self.skipped_channels[ccc] += 1
+            return False
+
+        orca_header_length = 10
+        # Find the number of Events that is in this packet.
+        num_of_events = packet[2]
+        num_of_longs = packet[3]
+        data_header_length = packet[5]
+
+        # Creating the first table and finding the index.
+        tbl = evt_rbkd[ccc].lgdo
+
+        for i in range(0, num_of_events):
+            # will crash if at the end of buffer.
+            ii = evt_rbkd[ccc].loc
+            if ii <= 8191:
+                # save the crate, card, and channel number which does not change
+                tbl["crate"].nda[ii] = crate
+                tbl["card"].nda[ii] = card
+                tbl["channel"].nda[ii] = channel
+
+                # calculates where to start indexing based on the num_of_longs
+                # for each event offset by the orca_header_length.
+                event_start = orca_header_length + num_of_longs * i
+
+                if len(packet) > 10:
+                    tbl["timestamp"].nda[ii] = packet[event_start + 1] + (
+                        (packet[event_start] & 0xFFFF0000) << 16
+                    )
+                else:
+                    tbl["timestamp"].nda[ii] = 0
+
+                index = event_start + 2
+                if (packet[event_start] & 0x1) == 1:
+                    tbl["peakHighValue"].nda[ii] = packet[index] & 0xFFFF
+                    tbl["peakHighIndex"].nda[ii] = (packet[index] >> 16) & 0xFFFF
+                    index += 1
+                    tbl["accSum1"].nda[ii] = packet[index] & 0xFFFFFF
+                    tbl["information"].nda[ii] = (packet[index] >> 24) & 0xFF
+                    index += 1
+                    tbl["accSum2"].nda[ii] = packet[index]
+                    index += 1
+                    tbl["accSum3"].nda[ii] = packet[index]
+                    index += 1
+                    tbl["accSum4"].nda[ii] = packet[index]
+                    index += 1
+                    tbl["accSum5"].nda[ii] = packet[index]
+                    index += 1
+                    tbl["accSum6"].nda[ii] = packet[index]
+                else:
+                    tbl["peakHighValue"].nda[ii] = -1
+                    tbl["peakHighIndex"].nda[ii] = -1
+                    tbl["accSum1"].nda[ii] = -1
+                    tbl["accSum2"].nda[ii] = -1
+                    tbl["accSum3"].nda[ii]
+                    tbl["accSum4"].nda[ii]
+                    tbl["accSum5"].nda[ii] = -1
+                    tbl["accSum6"].nda[ii] = -1
+
+                if ((packet[event_start] >> 1) & 0x1) == 1:
+                    tbl["accSum7"].nda[ii] = packet[index]
+                    index += 1
+                    tbl["accSum8"].nda[ii] = packet[index]
+                    index += 1
+                else:
+                    tbl["accSum7"].nda[ii] = -1
+                    tbl["accSum8"].nda[ii] = -1
+
+                if ((packet[event_start] >> 2) & 0x1) == 1:
+                    tbl["mawMax"].nda[ii] = packet[index]
+                    index += 1
+                    tbl["mawBefore"].nda[ii] = packet[index]
+                    index += 1
+                    tbl["mawAfter"].nda[ii] = packet[index]
+                    index += 1
+                else:
+                    tbl["mawMax"].nda[ii] = -1
+                    tbl["mawBefore"].nda[ii] = -1
+                    tbl["mawAfter"].nda[ii] = -1
+
+                if ((packet[event_start] >> 3) & 0x1) == 1:
+                    tbl["startEnergy"].nda[ii] = packet[index]
+                    index += 1
+                    tbl["maxEnergy"].nda[ii] = packet[index]
+                    index += 1
+                else:
+                    tbl["startEnergy"].nda[ii] = -1
+                    tbl["maxEnergy"].nda[ii] = -1
+
+                data_header_length16 = data_header_length * 2
+
+                expected_wf_length = num_of_longs * 2 - data_header_length16
+
+                i_wf_start = data_header_length16 + event_start * 2
+
+                i_wf_stop = i_wf_start + expected_wf_length
+
+                if expected_wf_length > 0:
+                    tbl["waveform"]["values"].nda[ii] = evt_data_16[
+                        i_wf_start:i_wf_stop
+                    ]
+
+                # move to the next index for the next event.
+                evt_rbkd[ccc].loc += 1
+
+        return evt_rbkd[ccc].is_full()
diff --git a/src/pygama/raw/orca/orca_packet.py b/src/pygama/raw/orca/orca_packet.py
index 6f757770b..3a1b86223 100644
--- a/src/pygama/raw/orca/orca_packet.py
+++ b/src/pygama/raw/orca/orca_packet.py
@@ -46,8 +46,13 @@ def hex_dump(
     as_int: bool = False,
     as_short: bool = False,
     id_dict: dict = None,
+    use_logging: bool = True,
 ) -> None:
 
+    dump_cmd = print  # noqa: T202
+    if use_logging:
+        dump_cmd = log.debug
+
     data_id = get_data_id(packet, shift=shift_data_id)
     n_words = get_n_words(packet)
     if id_dict is not None:
@@ -58,9 +63,9 @@ def hex_dump(
     else:
         heading = f"data ID = {data_id}"
     if print_n_words:
-        log.debug(f"{heading}: {n_words} words")
+        dump_cmd(f"{heading}: {n_words} words")
     else:
-        log.debug(f"{heading}:")
+        dump_cmd(f"{heading}:")
         n_to_print = int(np.minimum(n_words, max_words))
         pad = int(np.ceil(np.log10(n_to_print)))
         for i in range(n_to_print):
@@ -72,4 +77,4 @@ def hex_dump(
                 line += f" {packet[i]}"
             if as_short:
                 line += f" {np.frombuffer(packet[i:i+1].tobytes(), dtype='uint16')}"
-            log.debug(line)
+            dump_cmd(line)
diff --git a/src/pygama/raw/orca/orca_streamer.py b/src/pygama/raw/orca/orca_streamer.py
index 9deb5e87e..40f6fc905 100644
--- a/src/pygama/raw/orca/orca_streamer.py
+++ b/src/pygama/raw/orca/orca_streamer.py
@@ -9,7 +9,10 @@
 from pygama.raw.data_streamer import DataStreamer
 from pygama.raw.orca import orca_packet
 from pygama.raw.orca.orca_base import OrcaDecoder
-from pygama.raw.orca.orca_digitizers import ORSIS3302DecoderForEnergy  # noqa: F401
+from pygama.raw.orca.orca_digitizers import (  # noqa: F401
+    ORSIS3302DecoderForEnergy,
+    ORSIS3316WaveformDecoder,
+)
 from pygama.raw.orca.orca_flashcam import (  # noqa: F401
     ORFlashCamADCWaveformDecoder,
     ORFlashCamListenerConfigDecoder,
@@ -284,197 +287,3 @@ def read_packet(self) -> bool:
         rbl = self.rbl_id_dict[data_id]
         self.any_full |= decoder.decode_packet(packet, self.packet_id, rbl)
         return True
-
-        '''
-# Import orca_digitizers so that the list of OrcaDecoder.__subclasses__ gets populated
-# Do it here so that orca_digitizers can import the functions above here
-from . import orca_digitizers, orca_flashcam
-
-def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=None, verbose=False, buffer_size=1024):
-    """
-    convert ORCA DAQ data to "raw" lh5
-
-    ch_groups_dict: keyed by decoder_name
-    """
-    lh5_store = lh5.Store()
-
-    f_in = open_orca(daq_filename)
-    if f_in == None:
-        print("Couldn't find the file %s" % daq_filename)
-        sys.exit(0)
-
-    # parse the header. save the length so we can jump past it later
-    reclen, header_nbytes, header_dict = parse_header(daq_filename)
-
-    # figure out the total size
-    SEEK_END = 2
-    f_in.seek(0, SEEK_END)
-    file_size = float(f_in.tell())
-    f_in.seek(0, 0)  # rewind
-    file_size_MB = file_size / 1e6
-    print("Total file size: {:.3f} MB".format(file_size_MB))
-    print("Run number:", get_run_number(header_dict))
-
-
-    # Build the dict used in the inner loop for passing data packets to decoders
-    decoders = {}
-
-    # First build a list of all decoder names that might be in the data
-    # This is a dict of names keyed off of data_id
-    id2dn_dict = get_id_to_decoder_name_dict(header_dict)
-    if verbose:
-        print("Data IDs present in ORCA file header are:")
-        for data_id in id2dn_dict:
-            print(f"    {data_id}: {id2dn_dict[data_id]}")
-
-    # Invert the previous list, to get a list of decoder ids keyed off of
-    # decoder names
-    dn2id_dict = {name:data_id for data_id, name in id2dn_dict.items()}
-
-    # By default we decode all data for which we have decoders. If the user
-    # provides a ch_group_dict, we will only decode data from decoders keyed in
-    # the dict.
-    decode_all_data = True
-    decoders_to_run = dn2id_dict.keys()
-    if ch_groups_dict is not None:
-        decode_all_data = False
-        decoders_to_run = ch_groups_dict.keys()
-
-    # Now get the actual requested decoders
-    for sub in OrcaDecoder.__subclasses__():
-        decoder = sub() # instantiate the class
-        if decoder.decoder_name in decoders_to_run:
-            decoder.dataID = dn2id_dict[decoder.decoder_name]
-            decoder.set_header_dict(header_dict)
-            decoders[decoder.dataID] = decoder
-    if len(decoders) == 0:
-        print("No decoders. Exiting...")
-        sys.exit(1)
-    if verbose:
-        print("pygama will run these decoders:")
-        for data_id, dec in decoders.items():
-            print("   ", dec.decoder_name+ ", id =", data_id)
-
-    # Now cull the decoders_to_run list
-    new_dtr = []
-    for decoder_name in decoders_to_run:
-        data_id = dn2id_dict[decoder_name]
-        if data_id not in decoders.keys():
-            print("warning: no decoder exists for", decoder_name, "... will skip its data.")
-        else: new_dtr.append(decoder_name)
-    decoders_to_run = new_dtr
-
-    # prepare ch groups
-    if ch_groups_dict is None:
-        ch_groups_dict = {}
-        for decoder_name in decoders_to_run:
-            ch_groups = create_dummy_ch_groups()
-            ch_groups_dict[decoder_name] = ch_groups
-            grp_path_template = f'{decoder_name}/raw'
-            set_outputs(ch_groups, out_file_template=raw_file_pattern, grp_path_template=grp_path_template)
-    else:
-        for decoder_name, ch_groups in ch_groups_dict.items():
-            expand_ch_groups(ch_groups)
-            set_outputs(ch_groups, out_file_template=raw_file_pattern, grp_path_template='{system}/{group_name}/raw')
-
-    # Set up tables for data
-    ch_tables_dict = {}
-    for data_id, dec in decoders.items():
-        decoder_name = id2dn_dict[data_id]
-        ch_groups = ch_groups_dict[decoder_name]
-        ch_tables_dict[data_id] = build_tables(ch_groups, buffer_size, dec)
-    max_tbl_size = 0
-
-    # -- scan over raw data --
-    print("Beginning daq-to-raw processing ...")
-
-    packet_id = 0  # number of events decoded
-    unrecognized_data_ids = []
-
-    # skip the header using reclen from before
-    # reclen is in number of longs, and we want to skip a number of bytes
-    f_in.seek(reclen * 4)
-
-    n_entries = 0
-    unit = "B"
-    if n_max < np.inf and n_max > 0:
-        n_entries = n_max
-        unit = "id"
-    else:
-        n_entries = file_size
-    progress_bar = tqdm_range(0, int(n_entries), text="Processing", verbose=verbose, unit=unit)
-    file_position = 0
-
-    # start scanning
-    while (packet_id < n_max and f_in.tell() < file_size):
-        packet_id += 1
-
-        try:
-            packet, data_id = get_next_packet(f_in)
-        except EOFError:
-            break
-        except Exception as e:
-            print("Failed to get the next event ... Exception:", e)
-            break
-
-        if decode_all_data and data_id not in decoders:
-            if data_id not in unrecognized_data_ids:
-                unrecognized_data_ids.append(data_id)
-            continue
-
-        if data_id not in decoders: continue
-        decoder = decoders[data_id]
-
-        # Clear the tables if the next read could overflow them.
-        # Only have to check this when the max table size is within
-        # max_n_rows_per_packet of being full.
-        if max_tbl_size + decoder.max_n_rows_per_packet() >= buffer_size:
-            ch_groups = ch_groups_dict[id2dn_dict[data_id]]
-            max_tbl_size = 0
-            for group_info in ch_groups.values():
-                tbl = group_info['table']
-                if tbl.is_full():
-                    group_path = group_info['group_path']
-                    out_file = group_info['out_file']
-                    lh5_store.write_object(tbl, group_path, out_file, n_rows=tbl.loc)
-                    tbl.clear()
-                if tbl.loc > max_tbl_size: max_tbl_size = tbl.loc
-        else: max_tbl_size += decoder.max_n_rows_per_packet()
-
-        tables = ch_tables_dict[data_id]
-        decoder.decode_packet(packet, tables, packet_id, header_dict)
-
-        if verbose:
-            if n_max < np.inf and n_max > 0:
-                update_len = 1
-            else:
-                update_len = f_in.tell() - file_position
-                file_position = f_in.tell()
-            update_progress(progress_bar, update_len)
-
-
-    print("Done. Last packet ID:", packet_id)
-    f_in.close()
-
-    # final write to file
-    for dec_name, ch_groups in ch_groups_dict.items():
-        for group_info in ch_groups.values():
-            tbl = group_info['table']
-            if tbl.loc == 0: continue
-            group_path = group_info['group_path']
-            out_file = group_info['out_file']
-            lh5_store.write_object(tbl, group_path, out_file, n_rows=tbl.loc)
-            print('last write')
-            tbl.clear()
-
-    if len(unrecognized_data_ids) > 0:
-        print("WARNING, Found the following unknown data IDs:")
-        for data_id in unrecognized_data_ids:
-            try:
-                print("  {}: {}".format(data_id, id2dn_dict[data_id]))
-            except KeyError:
-                print("  {}: Unknown".format(data_id))
-        print("hopefully they weren't important!\n")
-
-    print("Wrote RAW File:\n    {}\nFILE INFO:".format(raw_file_pattern))
-        '''
diff --git a/src/pygama/raw/raw_buffer.py b/src/pygama/raw/raw_buffer.py
index 96a0ab87b..f201f1262 100644
--- a/src/pygama/raw/raw_buffer.py
+++ b/src/pygama/raw/raw_buffer.py
@@ -126,10 +126,24 @@ def is_full(self) -> bool:
         return (len(self) - self.loc) < self.fill_safety
 
     def __str__(self) -> str:
-        return f'RawBuffer {"{"} lgdo={self.lgdo}, key_list={self.key_list}, out_stream={self.out_stream}, out_name={self.out_name}, loc={self.loc}, fill_safety={self.fill_safety} {"}"}'
+        return repr(self)
 
     def __repr__(self) -> str:
-        return str(self)
+        return (
+            "RawBuffer(lgdo="
+            + repr(self.lgdo)
+            + ", key_list="
+            + repr(self.key_list)
+            + ", out_stream="
+            + repr(self.out_stream)
+            + ", out_name="
+            + repr(self.out_name)
+            + ", loc="
+            + repr(self.loc)
+            + ", fill_safety="
+            + repr(self.fill_safety)
+            + ")"
+        )
 
 
 class RawBufferList(list):
diff --git a/src/pygama/vis/waveform_browser.py b/src/pygama/vis/waveform_browser.py
index 0ed646191..87d5e1a55 100644
--- a/src/pygama/vis/waveform_browser.py
+++ b/src/pygama/vis/waveform_browser.py
@@ -91,11 +91,11 @@ def __init__(
             line colors and other style parameters to cycle through when
             drawing waveforms. Can be given as
 
-              - dictionary of lists (e.g. ``{'color':['r', 'g', 'b'],
-                'linestyle':['-', '--', '.']}``)
-              - name of predefined style (see :mod:`matplotlib.style`
-                documentation)
-              - ``None`` (use current :obj:`matplotlib.rcParams` style).
+            - dictionary of lists (e.g. ``{'color':['r', 'g', 'b'],
+              'linestyle':['-', '--', '.']}``)
+            - name of predefined style (see :mod:`matplotlib.style`
+              documentation)
+            - ``None`` (use current :obj:`matplotlib.rcParams` style).
 
             If a single style cycle is given, use for all lines; if a list is
             given, match to lines list.
@@ -119,11 +119,11 @@ def __init__(
         x_lim, y_lim
             range of x- or y-values and units passed as tuple.
 
-              - ``None``: Get range from first waveform drawn
-              - :class:`pint.Quantity`: set value and x-unit
-              - ``float``: get unit from first waveform drawn
-              - ``str``: convert to :class:`pint.Quantity` (e.g. ``('0*us',
-                '10*us')``).
+            - ``None``: Get range from first waveform drawn
+            - :class:`pint.Quantity`: set value and x-unit
+            - ``float``: get unit from first waveform drawn
+            - ``str``: convert to :class:`pint.Quantity` (e.g. ``('0*us',
+              '10*us')``).
 
         x_unit
             unit of x-axis.
@@ -149,12 +149,15 @@ def __init__(
         self.next_entry = 0
 
         # data i/o initialization
+        # HACK: do not read VOV "tracelist", cannot be handled correctly by LH5Iterator
+        # remove this hack once VOV support is properly implemented
         self.lh5_it = lh5.LH5Iterator(
             files_in,
             lh5_group,
             base_path=base_path,
             entry_list=entry_list,
             entry_mask=entry_mask,
+            field_mask={"tracelist": False},
             buffer_len=buffer_len,
         )
 
@@ -179,6 +182,7 @@ def __init__(
             self.lines = {line: [] for line in lines}
 
         # styles
+        default_style = cycler(plt.rcParams["axes.prop_cycle"])
         if isinstance(styles, (list, tuple)):
             self.styles = [None for _ in self.lines]
             for i, sty in enumerate(styles):
@@ -188,7 +192,7 @@ def __init__(
                     except KeyError:
                         self.styles[i] = itertools.repeat(None)
                 elif sty is None:
-                    self.styles[i] = itertools.repeat(None)
+                    self.styles[i] = default_style
                 else:
                     self.styles[i] = cycler(**sty)
         else:
@@ -198,7 +202,7 @@ def __init__(
                 except KeyError:
                     self.styles = itertools.repeat(None)
             elif styles is None:
-                self.styles = itertools.repeat(None)
+                self.styles = default_style
             else:
                 self.styles = cycler(**styles)
 
@@ -352,7 +356,7 @@ def find_entry(
             return
 
         if entry > len(self.lh5_it):
-            if safe:
+            if not safe:
                 raise IndexError
             else:
                 return
@@ -413,18 +417,18 @@ def find_entry(
                 unit = data.attrs.get("units", None)
                 if unit and unit in ureg and ureg.is_compatible_with(unit, self.x_unit):
                     # Vertical line
-                    val = val * float(ureg(unit) / self.x_unit) - ref_time
-                    lines.append(Line2D([val] * 2, [-lim, lim]))
+                    val = np.array([val * float(ureg(unit) / self.x_unit) - ref_time])
+                    lines.append(Line2D(np.tile(val, 2), [-lim, lim]))
                     self._update_auto_limit(val, None)
                 else:
                     # Horizontal line
-                    lines.append(Line2D([-lim, lim], [val / norm] * 2))
+                    lines.append(Line2D([-lim, lim], np.tile(val / norm, 2)))
                     self._update_auto_limit(None, val)
 
             elif data is None:
                 # Check for data in auxiliary table. It's unitless so I guess just do an hline...
-                val = self.aux_vals[name][entry] / norm
-                lines.append(Line2D([-lim, lim], [val] * 2))
+                val = np.array([self.aux_vals[name][entry] / norm])
+                lines.append(Line2D([-lim, lim], np.tile(val, 2)))
                 self._update_auto_limit(None, val)
 
             else:
@@ -482,11 +486,12 @@ def draw_current(self, clear: bool = True) -> None:
             styles = self.styles
 
         # draw lines
+        default_style = cycler(plt.rcParams["axes.prop_cycle"])
         for i, lines in enumerate(self.lines.values()):
             if isinstance(self.styles, list):
                 styles = self.styles[i]
             if styles is None:
-                styles = cycler(plt.rcparams)
+                styles = default_style
 
             for line, sty in zip(lines, styles):
                 if sty is not None:
@@ -512,7 +517,6 @@ def draw_current(self, clear: bool = True) -> None:
 
         # Draw legend
         self.ax.set_xlabel(self.x_unit)
-        self.ax.xaxis.set_label_coords(0.98, -0.05)
         if self.x_lim:
             self.ax.set_xlim(*self.x_lim)
         if len(leg_labels) > 0:
@@ -527,18 +531,29 @@ def draw_current(self, clear: bool = True) -> None:
 
     def _update_auto_limit(self, x: np.ndarray, y: np.ndarray) -> None:
         # Helper to update the automatic limits
-        y_where = {}
-        if isinstance(y, np.ndarray) and self.y_lim is not None:
-            y_where["where"] = (y >= self.y_lim[0]) & (y <= self.y_lim[1])
-        x_where = {}
-        if isinstance(x, np.ndarray) and self.x_lim is not None:
-            x_where["where"] = (x >= self.x_lim[0]) & (x <= self.x_lim[1])
+        where = True
+
+        if x is not None:
+            where &= np.isfinite(x)
+            if self.x_lim is not None:
+                where &= (x >= self.x_lim[0]) & (x <= self.x_lim[1])
+
+        if y is not None:
+            where &= np.isfinite(y)
+            if self.y_lim is not None:
+                where &= (y >= self.y_lim[0]) & (y <= self.y_lim[1])
+
         if x is not None:
-            self.auto_x_lim[0] = np.amin(x, **y_where, initial=self.auto_x_lim[0])
-            self.auto_x_lim[1] = np.amax(x, **y_where, initial=self.auto_x_lim[1])
+            self.auto_x_lim = [
+                np.amin(x, where=where, initial=self.auto_x_lim[0]),
+                np.amax(x, where=where, initial=self.auto_x_lim[1]),
+            ]
+
         if y is not None:
-            self.auto_y_lim[0] = np.amin(y, **y_where, initial=self.auto_y_lim[0])
-            self.auto_y_lim[1] = np.amax(y, **y_where, initial=self.auto_y_lim[1])
+            self.auto_y_lim = [
+                np.amin(y, where=where, initial=self.auto_y_lim[0]),
+                np.amax(y, where=where, initial=self.auto_y_lim[1]),
+            ]
 
     def draw_entry(
         self,
diff --git a/tutorials/IntroToDSP.ipynb b/tutorials/IntroToDSP.ipynb
deleted file mode 100644
index ae2eaa30c..000000000
--- a/tutorials/IntroToDSP.ipynb
+++ /dev/null
@@ -1,394 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Introduction to Digital Signal Processing\n",
-    "\n",
-    "Ian Guinn, UNC.  Presented at [LEGEND Software Tutorial, Nov. 2021](https://indico.legend-exp.org/event/561/)\n",
-    "\n",
-    "This notebook will provide a brief introduction to a few of LEGEND's bread and butter digital signal processing filters. These processors will be applied in sequence to digitized waveforms that are recorded to disk, and a brief explanation of why we use them is provided. The parameters of each processor may be varied as well in order to see the affects that they have. This goes along with C. Wiseman and I. Guinn's slides shown at the Early Career Showcase: https://indico.legend-exp.org/event/699/. Recommended reading is Knoll chapter 17, which introduces many pulse-shaping concepts (although, from a hardware-perspective more than a digital signal processing perspective; even so, the concepts are similar).\n",
-    "\n",
-    "### Set up the python environment\n",
-    "It is recommended that you use a file from the LEGEND test-data repository: https://github.com/legend-exp/legend-testdata; you may need to change data_file if you want to run this notebook yourself."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "#%matplotlib notebook\n",
-    "\n",
-    "from pygama.lh5.store import Store\n",
-    "from pygama.dsp.WaveformBrowser import WaveformBrowser\n",
-    "import pygama.dsp.units as units\n",
-    "import matplotlib.pyplot as plt\n",
-    "import os\n",
-    "\n",
-    "# data_file = os.path.expandvars(\"$LEGENDTESTDATA/data/lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5\")\n",
-    "data_file = '/global/cfs/cdirs/m2676/data/legend-testdata/data/lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5'\n",
-    "entry_no = 2 # which waveform in data_file to use\n",
-    "\n",
-    "plt.rcParams[\"figure.figsize\"]=(9, 6)\n",
-    "plt.rcParams[\"font.size\"] = 16\n"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Processor Setup\n",
-    "This next panel defines all of the processors that we are going to run. Normally this is done in a JSON file in almost the exact same format shown here. You are encouraged to try changing some of the values in these processors to see the effect on the analysis"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "dsp_config = { \"processors\": {\n",
-    "\n",
-    "    \"max_index\":{\n",
-    "        \"function\": \"argmax\",\n",
-    "        \"module\": \"numpy\",\n",
-    "        \"args\": [\"waveform\", 1, \"max_index\"],\n",
-    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
-    "        \"unit\": \"ADC\",\n",
-    "        \"prereqs\":[\"waveform\"]\n",
-    "    },\n",
-    "    \n",
-    "    \"bl , bl_sig, slope, intercept\":{\n",
-    "        \"function\": \"linear_slope_fit\",\n",
-    "        \"module\": \"pygama.dsp.processors\",\n",
-    "        \"args\" : [\"waveform[:1650]\", \"bl\",\"bl_sig\", \"slope\",\"intercept\"],\n",
-    "        \"unit\": [\"ADC\",\"ADC\",\"ADC\",\"ADC\"]\n",
-    "    },\n",
-    "\n",
-    "    \"wf_blsub\":{\n",
-    "        \"function\": \"subtract\",\n",
-    "        \"module\": \"numpy\",\n",
-    "        \"args\": [\"waveform\", \"bl\", \"wf_blsub\"],\n",
-    "        \"unit\": \"ADC\"\n",
-    "    },\n",
-    "\n",
-    "\n",
-    "    \"wf_pz\": {\n",
-    "        \"function\": \"pole_zero\",\n",
-    "        \"module\": \"pygama.dsp.processors\",\n",
-    "        \"args\": [\"wf_blsub\", \"180*us\", \"wf_pz\"],\n",
-    "        \"unit\": \"ADC\",\n",
-    "    },\n",
-    "\n",
-    "    \"wf_trap\": {\n",
-    "        \"function\": \"trap_norm\",\n",
-    "        \"module\": \"pygama.dsp.processors\",\n",
-    "        \"args\": [\"wf_pz\", \"8*us\", \"2*us\", \"wf_trap\"],\n",
-    "        \"unit\": \"ADC\"\n",
-    "    },\n",
-    "    \n",
-    "    \"trapEmax\": {\n",
-    "        \"function\": \"amax\",\n",
-    "        \"module\": \"numpy\",\n",
-    "        \"args\": [\"wf_trap\", 1, \"trapEmax\"],\n",
-    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->f\"]},\n",
-    "        \"unit\": \"ADC\",\n",
-    "    },\n",
-    "\n",
-    "    \"trapTmax\": {\n",
-    "        \"function\": \"argmax\",\n",
-    "        \"module\": \"numpy\",\n",
-    "        \"args\": [\"wf_trap\", 1, \"trapTmax\"],\n",
-    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
-    "        \"unit\": \"ns\"\n",
-    "    },\n",
-    "\n",
-    "    \"wf_atrap\": {\n",
-    "        \"function\": \"asym_trap_filter\",\n",
-    "        \"module\": \"pygama.dsp.processors\",\n",
-    "        \"args\": [\"wf_pz\", \"round(0.1*us)\", \"round(1*us)\", \"round(3*us)\", \"wf_atrap\"], # rise, flat, fall\n",
-    "        \"unit\": \"ADC\"\n",
-    "    },\n",
-    "    \n",
-    "    \"tmax\": {\n",
-    "        \"function\": \"argmax\",\n",
-    "        \"module\": \"numpy\",\n",
-    "        \"args\": [\"wf_atrap\", 1, \"tmax\"],\n",
-    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
-    "        \"unit\": \"ns\"\n",
-    "    },\n",
-    "    \n",
-    "    \"tp_0\": {\n",
-    "        \"function\": \"time_point_thresh\",\n",
-    "        \"module\": \"pygama.dsp.processors\",\n",
-    "        \"args\": [\"wf_atrap\", 0, \"tmax\", 0, \"tp_0\"],\n",
-    "        \"unit\": \"ns\",\n",
-    "    },\n",
-    "    \n",
-    "    \"ftp\": {\n",
-    "        \"function\": \"add\",\n",
-    "        \"module\": \"numpy\",\n",
-    "        \"args\": [\"tp_0\", \"round(2*us+8*us-0.25*us)\", \"ftp\"],\n",
-    "        \"unit\": \"ns\"\n",
-    "    },\n",
-    "    \n",
-    "    \"trapEftp\": {\n",
-    "        \"function\": \"fixed_time_pickoff\",\n",
-    "        \"module\": \"pygama.dsp.processors\",\n",
-    "        \"args\": [\"wf_trap\", \"ftp\", \"trapEftp\"],\n",
-    "        \"unit\": \"ADC\",\n",
-    "    },\n",
-    "    \n",
-    "    \"curr\": {\n",
-    "        \"function\": \"avg_current\",\n",
-    "        \"module\": \"pygama.dsp.processors\",\n",
-    "        \"args\": [\"wf_pz\", 1, \"curr(len(wf_pz)-1, f)\"],\n",
-    "        \"unit\": \"ADC\",\n",
-    "    },\n",
-    "    \n",
-    "    \"curr_gaus\": {\n",
-    "        \"function\": \"gaussian_filter1d\",\n",
-    "        \"module\": \"pygama.dsp._processors.gaussian_filter1d\",\n",
-    "        \"args\": [\"curr\", \"curr_gaus(len(curr), f)\"],\n",
-    "        \"init_args\": [\"50*ns\", 4.],\n",
-    "        \"unit\": \"ADC\",\n",
-    "    },\n",
-    "    \"A_gaus\": {\n",
-    "        \"function\": \"amax\",\n",
-    "        \"module\": \"numpy\",\n",
-    "        \"args\": [\"curr_gaus\", 1, \"A_gaus\"],\n",
-    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->f\"]},\n",
-    "        \"unit\": \"ADC\",\n",
-    "    },\n",
-    "    \n",
-    "    \n",
-    "    }   \n",
-    "}"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Raw Waveform\n",
-    "This is a waveform as produced by a flashcam digitizer. The waveform can be divided into three regions; first, the baseline, the flat part up to ~45000 ns. Next the rising edge, where it increases at ~45000 ns. Lastly, the falling tail, which is an approximately exponential return to baseline that begins after ~45000 ns"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(data_file, \"geds/raw\")\n",
-    "browser.draw_entry(entry_no)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Baseline Subtraction\n",
-    "The first processors applied perform baseline subtraction. First we measure the baseline using the first 1650 samples of the waveform, and then we subtract the baseline from each sample."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
-    "                          dsp_config=dsp_config,\n",
-    "                          waveforms=[\"waveform\", \"wf_blsub\"],\n",
-    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}])\n",
-    "browser.draw_entry(entry_no)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Pole-Zero Correction\n",
-    "The next step is to correct our waveform for the electronics response, which produces the exponential shape characteristic of our waveforms. The goal of this transform is to return a waveform with a perfectly flat tail. The pole-zero correction assumes a simple electronics response characterized by a single RC constant (in this case 180 us). In reality, our electronics can be characterized by multiple RC constants; as a result we do not achieve a perfectly flat top, but by applying more complex transforms at this step we can improve things. The pole-zero applied here is close enough for most applications, though. Knoll chapter 17 sec 7B has a more complete description."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
-    "                          dsp_config=dsp_config,\n",
-    "                          waveforms=[\"wf_blsub\", \"wf_pz\"],\n",
-    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
-    "                          x_lim=(40000, 70000))\n",
-    "browser.draw_entry(entry_no)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Trapezoidal Filter\n",
-    "The trapezoidal filter is a nearly optimal filter that is used to extract pulse amplitude for a variety of applications. The trapezoid is the difference, at each point, between the average over two sets of samples separated by a short time. It can be characterized by:\n",
-    "- Integration time: number of samples averaged, and the length of the rising and falling edges of the trapezoid. We use 8 us here. A longer integration time will reduce the effect of noise on our energy measurement (up to a limit that depends on low frequency noise; it also can't be too long for the number of samples collected).\n",
-    "- Flat top time: the separation between the integration regions, and the length of the flat top of the trapezoid. This should be longer than the rising edge of the waveform; if it is not, the top will not be totally flat and we will measure a reduction in the charge called \"ballistic deficit.\" We also don't want the flat top to be too long, or else low frequency noise will degrade our resolution. Here, we use 2 us as our flat top time.\n",
-    "\n",
-    "Knoll chapter 17 section 7 has a description of trapezoidal shaping."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
-    "                          dsp_config=dsp_config,\n",
-    "                          waveforms=[\"wf_pz\", \"wf_trap\"],\n",
-    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
-    "                          x_lim=(40000, 70000))\n",
-    "browser.draw_entry(entry_no)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Energy estimation: trap-max\n",
-    "The most common way to measure uncalibrated energy is to use the maximum of the trapezoidal filter. This works fairly well, assuming you have a nice flat top; however, due to our imperfect pole-zero correction, this can result in a degradation in our energy resolution"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
-    "                          dsp_config=dsp_config,\n",
-    "                          waveforms=[\"wf_trap\"],\n",
-    "                          wf_styles=[{'color':['r']}],\n",
-    "                          lines=[\"trapEmax\", \"trapTmax\"],\n",
-    "                          x_lim=(40000, 70000))\n",
-    "browser.draw_entry(entry_no)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Energy estimation: fixed time pick-off\n",
-    "Another way to measure energy is to pick-off the amplitude of the trapezoidal filter at a fixed time relative to the start of the waveform. The fixed time should fall on the flat top of the waveform, with enough padding to avoid ballistic deficit, and to prevent noise in the start time from pushing the pick-off time onto the falling edge of the waveform. We choose a pickoff time 0.25 us before the falling edge begins. This technique also requires an accurate measurement of the start time (t0) of the waveform (see next panel...)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
-    "                          dsp_config=dsp_config,\n",
-    "                          waveforms=[\"wf_trap\"],\n",
-    "                          wf_styles=[{'color':['r']}],\n",
-    "                          lines=[\"tp_0\", \"ftp\", \"trapEftp\"],\n",
-    "                          x_lim=(40000, 70000))\n",
-    "browser.draw_entry(entry_no)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Start time measurement\n",
-    "The start time (t0) of the waveform must be measured in order to apply the fixed-time pickoff. We use the following technique to measure this:\n",
-    "\n",
-    "1) Apply an asymmetric trapezoidal filter, which uses a short integration time for the rising edge and a long integration time for the falling tail. The short rising edge has the advantage of preserving most timing information in the rising edge. The longer tail helps us correct for low frequency noise. Here, we use 0.1-1-3] us as the rise-flat-fall times.\n",
-    "\n",
-    "2) Find the maximum of the asymmetric-trap, and walk back from there until we cross zero. The first sample that we cross this threshold is our t0.\n",
-    "\n",
-    "An accurate t0 is important both for the fixed-time pickoff, and for various drift-time corrections (such as for charge-trapping) that are not described in this notebook. While this filter is mostly reliable, it can occasionally fail for low signal-to-noise waveforms, or waveforms with large transiant blips in the noise."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
-    "                          dsp_config=dsp_config,\n",
-    "                          waveforms=[\"wf_pz\", \"wf_atrap\"],\n",
-    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
-    "                          lines=[\"tp_0\"],\n",
-    "                          x_lim=(40000, 70000))\n",
-    "browser.draw_entry(entry_no)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Current measurement\n",
-    "Current is needed to measure A/E. This can be found using a simple derivative filter. However, the signal-to-noise using just the derivative can be quite high, so we often apply additional smoothing filters after (next panel...)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
-    "                          dsp_config=dsp_config,\n",
-    "                          waveforms=[\"wf_pz\", \"curr\"],\n",
-    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
-    "                          x_lim=(40000, 55000))\n",
-    "browser.draw_entry(entry_no)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "### Gaussian-smoothed current\n",
-    "One way we can improve the signal-to-noise ratio is by applying a gaussian filter. This is a convolution of our waveform with a gaussian kernel. While this reduces the impact of noise on our measurement, it also reduces the timing information in the rising edge, reducing the signal; as a result, we must find an optimal balance in our smoothing. Knoll chapter 17 section 4 describes gaussian shaping.\n",
-    "\n",
-    "The current amplitude (A) is the maximum of the smoothed current waveform."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
-    "                          dsp_config=dsp_config,\n",
-    "                          waveforms=[\"curr\", \"curr_gaus\"],\n",
-    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
-    "                          lines=[\"A_gaus\"],\n",
-    "                          x_lim=(40000, 55000))\n",
-    "browser.draw_entry(entry_no)"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Conclusion\n",
-    "This notebook has hopefully provided an informative introduction to some of the signal processing filters used by LEGEND to perform our analysis. The filters shown often have tradeoffs between reducing the impact of noise and preserving the information in the original waveform; for this reason, we typically optimize the input parameters of each processor. In addition, the parameters measured here require calibration to be physically meaningful. The slides in https://indico.legend-exp.org/event/699/ will introduce how each of these processes are done.\n",
-    "\n"
-   ]
-  }
- ],
- "metadata": {},
- "nbformat": 4,
- "nbformat_minor": 4
-}
diff --git a/tutorials/WaveformBrowserTutorial.ipynb b/tutorials/WaveformBrowserTutorial.ipynb
deleted file mode 100644
index 3c308c150..000000000
--- a/tutorials/WaveformBrowserTutorial.ipynb
+++ /dev/null
@@ -1,308 +0,0 @@
-{
- "cells": [
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "# Introduction\n",
-    "\n",
-    "Ian Guinn, UNC\n",
-    "\n",
-    "Presented at [LEGEND Software Tutorial, Nov. 2021](https://indico.legend-exp.org/event/561/)\n",
-    "\n",
-    "**\"Have you tried looking at the waveforms from those events?\"  - David Radford**\n",
-    "\n",
-    "This is a tutorial demonstrating several ways to use the Waveform browser to examine data from LEGEND. This will consist of multiple examples, increasing in complexity, and will use data from the PGT. The waveform browser (pygama.dsp.WaveformBrowser) is a pygama utility for accessing waveforms from raw files in an interactive way, enabling you to access, draw, or even process waveforms. Some use cases for this utility include investigating a population of waveforms, and debugging waveform processors.\n",
-    "\n",
-    "Why do we need a waveform browser when we can access data via pandas dataframes?\n",
-    "Pandas dataframes work extremely well for reading tables of simple values from multiple HDF5 files. However, they are less optimal for waveforms. The reason for this is that they require holding all waveforms in memory at once. If we want to look at waveforms spread out across multiple files, this can potentially take up GBs of memory, which will cause problems! To get around this, we want to load only bits of the files into memory at a time and pull out only what we need. Since this is an inconvenient process, the WaveformBrowser will do this for you, while hiding the details as much as possible.\n",
-    "\n",
-    "## Contents:\n",
-    "**Example 1:** Minimal usage of the waveform browser  \n",
-    "**Example 2:** Draw waveforms using a data cut to investigate a population; fill a legend  \n",
-    "**Example 3:** Draw waveforms from multiple populations with different cuts for comparison; using more advanced formatting options  \n",
-    "**Example 4:** Draw processed waveforms from a DSP config file to inspect processors  \n",
-    "**Example 5:** Access waveforms without drawing"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "#First, import necessary modules and set some input values for use later\n",
-    "%matplotlib inline\n",
-    "import pygama.lgdo.lh5_store as lh5\n",
-    "from pygama.vis.waveform_browser import WaveformBrowser\n",
-    "import matplotlib.pyplot as plt\n",
-    "import pandas as pd\n",
-    "import numpy as np\n",
-    "import os, json\n",
-    "\n",
-    "# Set input values for where to find our data. This will grab all calibration runs from run 30, parsing wildcards\n",
-    "\n",
-    "# pgt_dir = '$LEGENDDATADIR/lngs/pgt/'\n",
-    "pgt_dir = '/global/cfs/cdirs/m2676/data/lngs/pgt/'\n",
-    "raw_files = pgt_dir + 'raw/geds/LPGTA_r0030_*_calib_geds_raw.lh5'\n",
-    "dsp_files = pgt_dir + 'dsp/geds/LPGTA_r0030_*_calib_geds_dsp.lh5'\n",
-    "channel = 'g040'\n",
-    "\n",
-    "# Set defaults for figures\n",
-    "plt.rcParams['figure.figsize'] = (12, 8)\n",
-    "plt.rcParams['font.size'] = 16"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Example 1\n",
-    "\n",
-    "First, a minimal example simply drawing waveforms from the raw file"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Create a minimal waveform browser; a file or list of files is required\n",
-    "browser = WaveformBrowser(raw_files, channel+'/raw')\n",
-    "\n",
-    "# Draw the 100th waveform in the file\n",
-    "browser.draw_entry(100)\n",
-    "\n",
-    "# To draw multiple figures in a single cell, you must explicitly create a new one:\n",
-    "browser.new_figure()\n",
-    "browser.draw_entry([200, 300, 400])"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Draw the next waveform in the file. You can run this cell multiple times to scroll through many WFs\n",
-    "browser.draw_next()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Example 2\n",
-    "Ok, that was nice, but how often do we just want to scroll through all of our waveforms?\n",
-    "\n",
-    "For our next example, we will select a population of waveforms from within the files, and draw multiple at once. Selecting a population of events to draw uses the same syntax as numpy and pandas, and can be done either with a list of entries or a boolean numpy array. This selection can be made using data from a dsp or hit hit file.\n",
-    "\n",
-    "We will also learn how to set a few other properties of the figure"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# First, load a dataframe from a DSP file that we can use to make our selection:\n",
-    "print(dsp_files)\n",
-    "df = lh5.load_dfs(dsp_files, ['trapEmax', 'AoE'], channel+'/dsp', verbose=True)"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Create a selection mask around the 2614 keV peak\n",
-    "trapE = df['trapEmax']\n",
-    "energy_selection = (trapE>13100) & (trapE<13400)\n",
-    "\n",
-    "trapE.hist(bins=1000, range=(0, 30000))\n",
-    "trapE[energy_selection].hist(bins=1000, range=(0, 30000))\n",
-    "plt.yscale('log')"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "# Now construct a WaveformBrowser with this cut\n",
-    "browser = WaveformBrowser(raw_files, channel+'/raw',\n",
-    "                          verbosity   = 0,                  # Silence output on construction\n",
-    "                          aux_values  = df,\n",
-    "                          legend      = 'energy={trapEmax}',       # Values to put in the legend\n",
-    "                          x_lim       = (22000, 30000),     # Range for time-axis\n",
-    "                          entry_mask  = energy_selection ,  # Apply cut\n",
-    "                          n_drawn     = 10                  # number to draw for draw_next\n",
-    "                         )\n",
-    "# Draw the next 5 batches of 10 waveforms, and move the legend outside\n",
-    "for entries, i in zip(browser, range(5)):\n",
-    "    print(\"Entries:\", entries)\n",
-    "    browser.new_figure()\n",
-    "    #plt.pause(1) # If you can use interactive plots (i.e. not on nersc), you can comment the above line and uncomment this one to draw a slideshow!\n"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Example 3\n",
-    "Now, we'll shift from drawing populations of waveforms to drawing waveform transforms. We can draw any waveforms that are defined in a DSP JSON configuration file. This is useful for debugging purposes and for developing processors. We will draw the baseline subtracted WF, pole-zero corrected WF, and trapezoidal filter WF. We will also draw horizontal and vertical lines for trapE (the max of the trapezoid) and tp_0 (our estimate of the start of the waveform's rise). The browser will determine whether these lines should be horizontal or vertical based on the unit."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(raw_files, channel+'/raw',\n",
-    "                          dsp_config=dsp_config_file, # Need to include a dsp config file!\n",
-    "                          database={\"pz_const\":'396.9*us'}, # TODO: use metadata instead of manually defining...\n",
-    "                          lines=['wf_blsub', 'wf_pz', 'wf_trap', 'trapEmax', 'tp_0'], # names of waveforms from dsp config file\n",
-    "                          styles=[{'ls':['-'], 'c':['orange']},\n",
-    "                                  {'ls':[':'], 'c':['green']},\n",
-    "                                  {'ls':['--'], 'c':['blue']},\n",
-    "                                  {'lw':[0.5], 'c':['black']},\n",
-    "                                  {'lw':[0.5], 'c':['red']}],\n",
-    "                          legend=['Waveform', 'PZ Corrected', \"Trap Filter\", 'Trap Max={trapEmax}', 't0={tp_0}'],\n",
-    "                          legend_opts={'loc':\"upper left\"},\n",
-    "                          x_lim=('15*us', '50*us') # x axis range\n",
-    "                         )"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser.draw_next()"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Example 4\n",
-    "Here's a more advanced example that combines the previous 2. We will draw waveforms from multiple populations for the sake of comparison. This will require creating two separate waveform browsers and drawing them onto the same axes. We'll also normalize and baseline subtract the waveforms from parameters in a DSP file. Finally, we'll add some formatting options to the lines and legend."
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "AoE = df['AoE']\n",
-    "aoe_cut = (AoE<0.045) & energy_selection\n",
-    "aoe_accept = (AoE>0.045) & energy_selection\n",
-    "\n",
-    "AoE[aoe_accept].hist(bins=200, range=(-0, 0.1))\n",
-    "AoE[aoe_cut].hist(bins=200, range=(-0, 0.1))\n",
-    "# Use the lpgta dsp json file. TODO: get this from DataGroup\n",
-    "# dsp_config_file = os.path.expandvars(\"$HOME/pygama/experiments/lpgta/LPGTA_dsp.json\")\n",
-    "dsp_config_file = os.path.expandvars(\"./metadata/LPGTA_dsp.json\")\n"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser1 = WaveformBrowser(raw_files, channel+'/raw',\n",
-    "                           dsp_config  = dsp_config_file, # include so we can do bl subtraction\n",
-    "                           lines       = 'wf_blsub',\n",
-    "                           norm        = 'trapEmax',        # normalize wfs\n",
-    "                           verbosity   = 0,                 # Silence output on construction\n",
-    "                           styles      = {'color':['red', 'orange', 'salmon', 'magenta']}, # set a color cycle for this\n",
-    "                           legend      = \"E={trapEmax} ADC, A/E={AoE:~.3f}\", # Formatted values to put in the legend\n",
-    "                           entry_mask  = aoe_cut,           # Apply cut\n",
-    "                           n_drawn     = 4                  # number to draw for draw_next\n",
-    "                          )\n",
-    "\n",
-    "browser2 = WaveformBrowser(raw_files, channel+'/raw',\n",
-    "                           dsp_config  = dsp_config_file, # include so we can do bl subtraction\n",
-    "                           lines       = 'wf_blsub',\n",
-    "                           norm        = 'trapEmax',        # normalize wfs\n",
-    "                           verbosity   = 0,                 # Silence output on construction\n",
-    "                           styles      = {'color':['blue', 'navy', 'cyan', 'teal']}, # set a color cycle for this\n",
-    "                           legend      = \"E={trapEmax} ADC, A/E={AoE:~.3f}\", # Formatted values to put in the legend\n",
-    "                           legend_opts = {'loc':\"center\",'bbox_to_anchor':(1,0.35)}, # set options for drawing the legend\n",
-    "                           x_lim       = (26500, 28000),    # Range for time-axis\n",
-    "                           entry_mask  = aoe_accept,           # Apply cut\n",
-    "                           n_drawn     = 4                  # number to draw for draw_next\n",
-    "                          )"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser1.draw_next()\n",
-    "browser2.set_figure(browser1) # use the same figure/axis as the other browser\n",
-    "browser2.draw_next(clear=False) # Set clear to false to draw on the same axis!"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
-    "## Example 5\n",
-    "\n",
-    "The waveforms, lines and legend entries are all stored inside of the waveform browser. Sometimes you want to access these directly; maybe you want to access the raw data, or do control the lines in a way not enabled by the WaveformBrowser interface. It is possible to access them quickly and easily. Waveforms and legend values are stored as a dict from the parameter name to a list of stored values.\n",
-    "- The waveforms are as a list of matplotlib Line2D artists\n",
-    "- Horizontal and vertical lines are also stored as Line2D artists\n",
-    "- Legend entries are stored as pint Quanitities\n",
-    "\n",
-    "When accessing waveforms in this way, you can also do the same things previously shown, such as applying a data cut and grabbing processed waveforms. For this example, we are going to get waveforms, trap-waveforms and trap energies, after applying an A/E cut. We will simply print them, but the possibility exists to do more!"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser = WaveformBrowser(raw_files, channel+'/raw',\n",
-    "                          dsp_config = dsp_config_file,                # Need to include a dsp config file!\n",
-    "                          database   = {\"pz_const\":'396.9*us'},        # TODO: use metadata instead of manually defining...\n",
-    "                          lines      = ['waveform', 'wf_trap'],        # names of waveforms from dsp config file\n",
-    "                          legend     = ['{trapEmax}'],\n",
-    "                          entry_mask = aoe_accept,                     # apply A/E cut\n",
-    "                          n_drawn    = 5                               # get five at a time\n",
-    "                         )"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "browser.find_next()\n",
-    "waveforms = browser.lines['waveform']\n",
-    "traps = browser.lines['wf_trap']\n",
-    "energies = browser.legend_vals['trapEmax']\n",
-    "for wf, trap, en in zip(waveforms, traps, energies):\n",
-    "    print(\"Raw waveform:\", wf.get_ydata())\n",
-    "    print(\"Trap-filtered waveform:\", trap.get_ydata())\n",
-    "    print(\"TrapEmax:\", en)\n",
-    "    print()"
-   ]
-  }
- ],
- "metadata": {},
- "nbformat": 4,
- "nbformat_minor": 4
-}


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index ab6684291..99c7e57b9 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,9 +1,133 @@
+import inspect
+import os
+import re
+from pathlib import Path
+
+import numpy as np
 import pytest
 from legend_testdata import LegendTestData
 
+from pygama.dsp import build_dsp
+from pygama.raw import build_raw
+
+config_dir = Path(__file__).parent / "dsp" / "configs"
+
 
 @pytest.fixture(scope="session")
 def lgnd_test_data():
     ldata = LegendTestData()
-    ldata.checkout("968c9ba")
+    ldata.checkout("39f9927")
     return ldata
+
+
+@pytest.fixture(scope="session")
+def dsp_test_file(lgnd_test_data):
+    out_name = "/tmp/LDQTA_r117_20200110T105115Z_cal_geds_dsp.lh5"
+    build_dsp(
+        lgnd_test_data.get_path("lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5"),
+        out_name,
+        dsp_config=f"{config_dir}/icpc-dsp-config.json",
+        database={"pz": {"tau": 27460.5}},
+        write_mode="r",
+    )
+    assert os.path.exists(out_name)
+
+    return out_name
+
+
+@pytest.fixture(scope="session")
+def multich_raw_file(lgnd_test_data):
+    out_file = "/tmp/L200-comm-20211130-phy-spms.lh5"
+    out_spec = {
+        "FCEventDecoder": {
+            "ch{key}": {
+                "key_list": [[0, 6]],
+                "out_stream": out_file + ":{name}",
+                "out_name": "raw",
+            }
+        }
+    }
+
+    build_raw(
+        in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
+        out_spec=out_spec,
+        overwrite=True,
+    )
+    assert os.path.exists(out_file)
+
+    return out_file
+
+
+@pytest.fixture(scope="session")
+def dsp_test_file_spm(multich_raw_file):
+    chan_config = {
+        "ch0/raw": f"{config_dir}/sipm-dsp-config.json",
+        "ch1/raw": f"{config_dir}/sipm-dsp-config.json",
+        "ch2/raw": f"{config_dir}/sipm-dsp-config.json",
+    }
+
+    out_file = "/tmp/L200-comm-20211130-phy-spms_dsp.lh5"
+    build_dsp(
+        multich_raw_file,
+        out_file,
+        {},
+        n_max=5,
+        lh5_tables=chan_config.keys(),
+        chan_config=chan_config,
+        write_mode="r",
+    )
+
+    assert os.path.exists(out_file)
+
+    return out_file
+
+
+@pytest.fixture(scope="session")
+def compare_numba_vs_python():
+    def numba_vs_python(func, *inputs):
+        """
+        Function for testing that the numba and python versions of a
+        function are equal.
+
+        Parameters
+        ----------
+        func
+            The Numba-wrapped function to be tested
+        *inputs
+            The various inputs to be passed to the function to be
+            tested.
+
+        Returns
+        -------
+        func_output
+            The output of the function to be used in a unit test.
+
+        """
+        # parse outputs from function signature
+        all_params = list(inspect.signature(func).parameters)
+        output_sizes = re.findall(r"(\(n*\))", func.signature.split("->")[-1])
+        noutputs = len(output_sizes)
+        output_names = all_params[-noutputs:]
+
+        # numba outputs
+        outputs_numba = func(*inputs)
+        if noutputs == 1:
+            outputs_numba = [outputs_numba]
+
+        # unwrapped python outputs
+        func_unwrapped = inspect.unwrap(func)
+        output_dict = {key: np.empty(len(inputs[0])) for key in output_names}
+        func_unwrapped(*inputs, **output_dict)
+        for spec, key in zip(output_sizes, output_dict):
+            if spec == "()":
+                output_dict[key] = output_dict[key][0]
+        outputs_python = [output_dict[key] for key in output_names]
+
+        # assert that numba and python are the same up to floating point
+        # precision, setting nans to be equal
+        assert np.allclose(outputs_numba, outputs_python, equal_nan=True)
+
+        # return value for comparison with expected solution
+        return outputs_numba[0] if noutputs == 1 else outputs_numba
+
+    return numba_vs_python
diff --git a/tests/dsp/configs/icpc-dsp-config.json b/tests/dsp/configs/icpc-dsp-config.json
index 0168da525..2ca70c78b 100644
--- a/tests/dsp/configs/icpc-dsp-config.json
+++ b/tests/dsp/configs/icpc-dsp-config.json
@@ -138,6 +138,7 @@
       "args": [
         "wf_etrap",
         "tp_0_est+db.etrap.rise+db.etrap.flat*db.etrap.sample",
+        "'l'",
         "trapEftp"
       ],
       "unit": "ADC",
@@ -174,7 +175,7 @@
     "cuspEftp": {
       "function": "fixed_time_pickoff",
       "module": "pygama.dsp.processors",
-      "args": ["wf_cusp", "db.cusp.sample", "cuspEftp"],
+      "args": ["wf_cusp", "db.cusp.sample", "'i'", "cuspEftp"],
       "unit": "ADC",
       "defaults": { "db.cusp.sample": "50" }
     },
@@ -205,7 +206,7 @@
     "zacEftp": {
       "function": "fixed_time_pickoff",
       "module": "pygama.dsp.processors",
-      "args": ["wf_zac", "db.zac.sample", "zacEftp"],
+      "args": ["wf_zac", "db.zac.sample", "'i'", "zacEftp"],
       "defaults": { "db.zac.sample": "50" },
       "unit": "ADC"
     },
@@ -272,7 +273,7 @@
     "trapQftp": {
       "function": "fixed_time_pickoff",
       "module": "pygama.dsp.processors",
-      "args": ["wf_trap2", "tp_0_est + 8.096*us", "trapQftp"],
+      "args": ["wf_trap2", "tp_0_est + 8.096*us", "'l'", "trapQftp"],
       "unit": "ADC"
     },
     "QDrift": {
diff --git a/tests/dsp/configs/numpy-parsing.json b/tests/dsp/configs/numpy-parsing.json
new file mode 100644
index 000000000..07d3f498f
--- /dev/null
+++ b/tests/dsp/configs/numpy-parsing.json
@@ -0,0 +1,43 @@
+{
+  "outputs": [
+    "timestamp",
+    "calc1",
+    "calc2",
+    "calc3",
+    "calc4",
+    "calc5",
+    "calc6"
+  ],
+  "processors": {
+    "calc1": {
+      "function": "subtract",
+      "module": "numpy",
+      "args": ["timestamp-timestamp", "np.pi*timestamp", "calc1"]
+    },
+    "calc2": {
+      "function": "subtract",
+      "module": "numpy",
+      "args": ["timestamp-timestamp", "np.pi", "calc2"]
+    },
+    "calc3": {
+      "function": "subtract",
+      "module": "numpy",
+      "args": ["timestamp-timestamp", "np.pi*np.e", "calc3"]
+    },
+    "calc4": {
+      "function": "subtract",
+      "module": "numpy",
+      "args": ["timestamp-timestamp", "np.nan", "calc4"]
+    },
+    "calc5": {
+      "function": "subtract",
+      "module": "numpy",
+      "args": ["timestamp-timestamp", "np.inf", "calc5"]
+    },
+    "calc6": {
+      "function": "subtract",
+      "module": "numpy",
+      "args": ["timestamp-timestamp", "np.nan*timestamp", "calc6"]
+    }
+  }
+}
diff --git a/tests/dsp/configs/sipm-dsp-config.json b/tests/dsp/configs/sipm-dsp-config.json
index 3b015024c..7099bd0e3 100644
--- a/tests/dsp/configs/sipm-dsp-config.json
+++ b/tests/dsp/configs/sipm-dsp-config.json
@@ -1,23 +1,72 @@
 {
-  "outputs": ["bl_mean", "bl_std"],
+  "outputs": ["energies", "trigger_pos"],
   "processors": {
-    "wf_blsub": {
-      "function": "bl_subtract",
-      "module": "pygama.dsp.processors",
-      "args": ["waveform", "baseline", "wf_blsub"],
+    "wf_gaus": {
+      "function": "gaussian_filter1d",
+      "module": "pygama.dsp.processors.gaussian_filter1d",
+      "args": ["waveform", "wf_gaus(len(waveform))"],
+      "init_args": ["1", "4.0"],
       "unit": "ADC"
     },
-    "bl_mean , bl_std, bl_slope, bl_intercept": {
-      "function": "linear_slope_fit",
-      "module": "pygama.dsp.processors",
+    "curr": {
+      "function": "avg_current",
+      "module": "pygama.dsp.processors.moving_windows",
+      "args": ["wf_gaus", 5, "curr(len(wf_gaus)-5)"],
+      "unit": "ADC"
+    },
+    "hist_weights , hist_borders": {
+      "function": "histogram",
+      "module": "pygama.dsp.processors.histogram",
+      "args": ["curr", "hist_weights(100)", "hist_borders(101)"],
+      "unit": ["none", "ADC"]
+    },
+    "fwhm, idx_out_c, max_out": {
+      "function": "histogram_stats",
+      "module": "pygama.dsp.processors.histogram",
+      "args": [
+        "hist_weights",
+        "hist_borders",
+        "idx_out_c",
+        "max_out",
+        "fwhm",
+        "np.nan"
+      ],
+      "unit": ["ADC", "non", "ADC"]
+    },
+    "vt_max_candidate_out, vt_min_out, n_max_out, n_min_out, flag_out": {
+      "function": "get_multi_local_extrema",
+      "module": "pygama.dsp.processors.get_multi_local_extrema",
       "args": [
-        "wf_blsub[0:750]",
-        "bl_mean",
-        "bl_std",
-        "bl_slope",
-        "bl_intercept"
+        "curr",
+        5,
+        "3*fwhm",
+        0,
+        "vt_max_candidate_out(20)",
+        "vt_min_out(20)",
+        "n_max_out",
+        "n_min_out",
+        "flag_out"
       ],
-      "unit": ["ADC", "ADC", "ADC", "ADC"]
+      "unit": ["ns", "ns", "none", "none", "none"]
+    },
+    "trigger_pos, no_out": {
+      "function": "peak_snr_threshold",
+      "module": "pygama.dsp.processors.peak_snr_threshold",
+      "args": [
+        "curr",
+        "vt_max_candidate_out",
+        0.8,
+        10,
+        "trigger_pos",
+        "no_out"
+      ],
+      "unit": ["ns", "none"]
+    },
+    "energies": {
+      "function": "multi_a_filter",
+      "module": "pygama.dsp.processors.multi_a_filter",
+      "args": ["curr", "trigger_pos", "energies"],
+      "unit": ["ADC"]
     }
   }
 }
diff --git a/tests/dsp/processors/test_fixed_time_pickoff.py b/tests/dsp/processors/test_fixed_time_pickoff.py
new file mode 100644
index 000000000..b2634f4fe
--- /dev/null
+++ b/tests/dsp/processors/test_fixed_time_pickoff.py
@@ -0,0 +1,88 @@
+import inspect
+
+import numpy as np
+import pytest
+
+from pygama.dsp.errors import DSPFatal
+from pygama.dsp.processors import fixed_time_pickoff
+
+
+def test_fixed_time_pickoff(compare_numba_vs_python):
+    """Testing function for the fixed_time_pickoff processor."""
+
+    len_wf = 20
+
+    # test for nan if w_in has a nan
+    w_in = np.ones(len_wf)
+    w_in[4] = np.nan
+    assert np.isnan(compare_numba_vs_python(fixed_time_pickoff, w_in, 1, ord("i")))
+
+    # test for nan if nan is passed to t_in
+    w_in = np.ones(len_wf)
+    assert np.isnan(compare_numba_vs_python(fixed_time_pickoff, w_in, np.nan, ord("i")))
+
+    # test for nan if t_in is negative
+    w_in = np.ones(len_wf)
+    assert np.isnan(compare_numba_vs_python(fixed_time_pickoff, w_in, -1, ord("i")))
+
+    # test for nan if t_in is too large
+    w_in = np.ones(len_wf)
+    assert np.isnan(compare_numba_vs_python(fixed_time_pickoff, w_in, len_wf, ord("i")))
+
+    # test for DSPFatal errors being raised
+    # noninteger t_in with integer interpolation
+    with pytest.raises(DSPFatal):
+        w_in = np.ones(len_wf)
+        fixed_time_pickoff(w_in, 1.5, ord("i"))
+    with pytest.raises(DSPFatal):
+        a_out = np.empty(len_wf)
+        inspect.unwrap(fixed_time_pickoff)(w_in, 1.5, ord("i"), a_out)
+
+    # unsupported mode_in character
+    with pytest.raises(DSPFatal):
+        w_in = np.ones(len_wf)
+        fixed_time_pickoff(w_in, 1.5, ord(" "))
+    with pytest.raises(DSPFatal):
+        a_out = np.empty(len_wf)
+        inspect.unwrap(fixed_time_pickoff)(w_in, 1.5, ord(" "), a_out)
+
+    # linear tests
+    w_in = np.arange(len_wf, dtype=float)
+    assert compare_numba_vs_python(fixed_time_pickoff, w_in, 3, ord("i")) == 3
+
+    chars = ["n", "f", "c", "l", "h", "s"]
+    sols = [4, 3, 4, 3.5, 3.5, 3.5]
+
+    for char, sol in zip(chars, sols):
+        assert compare_numba_vs_python(fixed_time_pickoff, w_in, 3.5, ord(char)) == sol
+
+    # sine wave tests
+    w_in = np.sin(np.arange(len_wf))
+
+    chars = ["n", "f", "c", "l", "h", "s"]
+    sols = [
+        0.1411200080598672,
+        0.1411200080598672,
+        -0.7568024953079282,
+        -0.08336061778208165,
+        -0.09054574599004982,
+        -0.10707938709427486,
+    ]
+
+    for char, sol in zip(chars, sols):
+        assert np.isclose(
+            compare_numba_vs_python(fixed_time_pickoff, w_in, 3.25, ord(char)), sol
+        )
+
+    # last few corner cases of 'h'
+    w_in = np.sin(np.arange(len_wf))
+    ftps = [0.2, len_wf - 1.8]
+    sols = [
+        0.1806725096462211,
+        -0.6150034250096629,
+    ]
+
+    for ftp, sol in zip(ftps, sols):
+        assert np.isclose(
+            compare_numba_vs_python(fixed_time_pickoff, w_in, ftp, ord("h")), sol
+        )
diff --git a/tests/dsp/test_build_dsp.py b/tests/dsp/test_build_dsp.py
index c1685cc65..bddb20eca 100644
--- a/tests/dsp/test_build_dsp.py
+++ b/tests/dsp/test_build_dsp.py
@@ -1,4 +1,3 @@
-import os
 from pathlib import Path
 
 import pytest
@@ -6,44 +5,11 @@
 from pygama import lgdo
 from pygama.dsp import build_dsp
 from pygama.lgdo.lh5_store import LH5Store, ls
-from pygama.raw import build_raw
 
 config_dir = Path(__file__).parent / "configs"
 
 
-@pytest.fixture(scope="module")
-def multich_raw_file(lgnd_test_data):
-    out_file = "/tmp/L200-comm-20211130-phy-spms.lh5"
-    out_spec = {
-        "FCEventDecoder": {
-            "ch{key}": {
-                "key_list": [[0, 6]],
-                "out_stream": out_file + ":{name}",
-                "out_name": "raw",
-            }
-        }
-    }
-
-    build_raw(
-        in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
-        out_spec=out_spec,
-        overwrite=True,
-    )
-
-    return out_file
-
-
-def test_build_dsp_basics(lgnd_test_data):
-    build_dsp(
-        lgnd_test_data.get_path("lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5"),
-        "/tmp/LDQTA_r117_20200110T105115Z_cal_geds_dsp.lh5",
-        dsp_config=f"{config_dir}/icpc-dsp-config.json",
-        database={"pz": {"tau": 27460.5}},
-        write_mode="r",
-    )
-
-    assert os.path.exists("/tmp/LDQTA_r117_20200110T105115Z_cal_geds_dsp.lh5")
-
+def test_build_dsp_basics(lgnd_test_data, dsp_test_file):
     with pytest.raises(FileExistsError):
         build_dsp(
             lgnd_test_data.get_path("lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5"),
@@ -60,29 +26,17 @@ def test_build_dsp_basics(lgnd_test_data):
         )
 
 
-def test_build_dsp_channelwise(multich_raw_file):
-    chan_config = {
-        "ch0/raw": f"{config_dir}/sipm-dsp-config.json",
-        "ch1/raw": f"{config_dir}/sipm-dsp-config.json",
-        "ch2/raw": f"{config_dir}/sipm-dsp-config.json",
-    }
-
-    out_file = "/tmp/L200-comm-20211130-phy-spms_dsp.lh5"
-    build_dsp(
-        multich_raw_file,
-        out_file,
-        {},
-        n_max=5,
-        lh5_tables=chan_config.keys(),
-        chan_config=chan_config,
-        write_mode="r",
-    )
+def test_build_dsp_spms_channelwise(dsp_test_file_spm):
 
-    assert ls(out_file) == ["ch0", "ch1", "ch2", "dsp_info"]
-    assert ls(out_file, "ch0/") == ["ch0/dsp"]
-    assert ls(out_file, "ch0/dsp/") == ["ch0/dsp/bl_mean", "ch0/dsp/bl_std"]
+    assert ls(dsp_test_file_spm) == ["ch0", "ch1", "ch2", "dsp_info"]
+    assert ls(dsp_test_file_spm, "ch0/") == ["ch0/dsp"]
+    assert ls(dsp_test_file_spm, "ch0/dsp/") == [
+        "ch0/dsp/energies",
+        "ch0/dsp/trigger_pos",
+    ]
 
     store = LH5Store()
-    lh5_obj, n_rows = store.read_object("/ch0/dsp/bl_mean", out_file)
-    assert isinstance(lh5_obj, lgdo.Array)
+    lh5_obj, n_rows = store.read_object("/ch0/dsp/energies", dsp_test_file_spm)
+    assert isinstance(lh5_obj, lgdo.ArrayOfEqualSizedArrays)
     assert len(lh5_obj) == 5
+    assert len(lh5_obj.nda[0]) == 20
diff --git a/tests/dsp/test_histogram.py b/tests/dsp/test_histogram.py
new file mode 100644
index 000000000..1a3c086c7
--- /dev/null
+++ b/tests/dsp/test_histogram.py
@@ -0,0 +1,39 @@
+import os
+from pathlib import Path
+
+import pygama.lgdo.lh5_store as store
+from pygama.dsp import build_dsp
+
+config_dir = Path(__file__).parent / "configs"
+dsp_file = "/tmp/LDQTA_r117_20200110T105115Z_cal_geds__numpy_test_dsp.lh5"
+
+
+def test_histogram_fixed_width(lgnd_test_data):
+    dsp_config = {
+        "outputs": ["hist_weights", "hist_borders"],
+        "processors": {
+            "hist_weights , hist_borders": {
+                "function": "histogram",
+                "module": "pygama.dsp.processors.histogram",
+                "args": ["waveform", "hist_weights(100)", "hist_borders(101)"],
+                "unit": ["none", "ADC"],
+            }
+        },
+    }
+    build_dsp(
+        f_raw=lgnd_test_data.get_path(
+            "lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5"
+        ),
+        f_dsp=dsp_file,
+        dsp_config=dsp_config,
+        write_mode="r",
+    )
+    assert os.path.exists(dsp_file)
+
+    df = store.load_nda(dsp_file, ["hist_weights", "hist_borders"], "geds/dsp/")
+
+    assert len(df["hist_weights"][0]) + 1 == len(df["hist_borders"][0])
+    for i in range(2, len(df["hist_borders"][0])):
+        a = df["hist_borders"][0][i - 1] - df["hist_borders"][0][i - 2]
+        b = df["hist_borders"][0][i] - df["hist_borders"][0][i - 1]
+        assert round(a, 2) == round(b, 2)
diff --git a/tests/dsp/test_list_parsing.py b/tests/dsp/test_list_parsing.py
new file mode 100644
index 000000000..bb91826ef
--- /dev/null
+++ b/tests/dsp/test_list_parsing.py
@@ -0,0 +1,38 @@
+import os
+from pathlib import Path
+
+import numpy as np
+
+import pygama.lgdo.lh5_store as store
+from pygama.dsp import build_dsp
+
+config_dir = Path(__file__).parent / "configs"
+dsp_file = "/tmp/LDQTA_r117_20200110T105115Z_cal_geds__numpy_test_dsp.lh5"
+
+
+def test_list_parisng(lgnd_test_data):
+    dsp_config = {
+        "outputs": ["wf_out"],
+        "processors": {
+            "wf_out": {
+                "function": "add",
+                "module": "numpy",
+                "args": ["[1,2,3,4,5]", "[6,7,8,9,10]", "out=wf_out"],
+                "kwargs": {"signature": "(n),(n),->(n)", "types": ["ff->f"]},
+                "unit": "ADC",
+            },
+        },
+    }
+    build_dsp(
+        f_raw=lgnd_test_data.get_path(
+            "lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5"
+        ),
+        f_dsp=dsp_file,
+        dsp_config=dsp_config,
+        write_mode="r",
+    )
+    assert os.path.exists(dsp_file)
+
+    df = store.load_nda(dsp_file, ["wf_out"], "geds/dsp/")
+
+    assert np.all(df["wf_out"][:] == np.array([7, 9, 11, 13, 15]))
diff --git a/tests/dsp/test_numpy_constants_parsing.py b/tests/dsp/test_numpy_constants_parsing.py
new file mode 100644
index 000000000..2f3db4cb2
--- /dev/null
+++ b/tests/dsp/test_numpy_constants_parsing.py
@@ -0,0 +1,46 @@
+import os
+from pathlib import Path
+
+import numpy as np
+
+import pygama.lgdo.lh5_store as store
+from pygama.dsp import build_dsp
+
+config_dir = Path(__file__).parent / "configs"
+dsp_file = "/tmp/LDQTA_r117_20200110T105115Z_cal_geds__numpy_test_dsp.lh5"
+
+
+def test_build_dsp(lgnd_test_data):
+    build_dsp(
+        f_raw=lgnd_test_data.get_path(
+            "lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5"
+        ),
+        f_dsp=dsp_file,
+        dsp_config=f"{config_dir}/numpy-parsing.json",
+        write_mode="r",
+    )
+    assert os.path.exists(dsp_file)
+
+
+def test_numpy_math_constants_dsp():
+    df = store.load_nda(dsp_file, ["timestamp", "calc1", "calc2", "calc3"], "geds/dsp/")
+
+    a1 = df["timestamp"] - df["timestamp"] - np.pi * df["timestamp"]
+    a2 = df["timestamp"] - df["timestamp"] - np.pi
+    a3 = df["timestamp"] - df["timestamp"] - np.pi * np.e
+
+    f1 = df["calc1"]
+    f2 = df["calc2"]
+    f3 = df["calc3"]
+
+    assert (a1 == f1).all()
+    assert (a2 == f2).all()
+    assert (a3 == f3).all()
+
+
+def test_numpy_infinity_and_nan_dsp():
+    df = store.load_nda(dsp_file, ["calc4", "calc5", "calc6"], "geds/dsp/")
+
+    assert (np.isnan(df["calc4"])).all()
+    assert (np.isneginf(df["calc5"])).all()
+    assert (np.isnan(df["calc6"])).all()
diff --git a/tests/dsp/test_processing_chain.py b/tests/dsp/test_processing_chain.py
index bef13e5e8..7838c26cc 100644
--- a/tests/dsp/test_processing_chain.py
+++ b/tests/dsp/test_processing_chain.py
@@ -107,16 +107,34 @@ def test_scipy_gauss_filter(geds_raw_tbl):
     proc_chain.execute(0, 1)
 
 
+def test_histogram_processor_fixed_witdth(spms_raw_tbl):
+    dsp_config = {
+        "outputs": ["hist_weights", "hist_borders"],
+        "processors": {
+            "hist_weights , hist_borders": {
+                "function": "histogram",
+                "module": "pygama.dsp.processors.histogram",
+                "args": ["waveform", "hist_weights(100)", "hist_borders(101)"],
+                "unit": ["none", "ADC"],
+            }
+        },
+    }
+    proc_chain, _, _ = build_processing_chain(spms_raw_tbl, dsp_config)
+    proc_chain.execute(0, 1)
+
+
 def test_processor_variable_array_output(spms_raw_tbl):
     dsp_config = {
-        "outputs": ["wf_cum"],
+        "outputs": ["vt_max_out"],
         "processors": {
             "vt_max_out, vt_min_out, n_max_out, n_min_out, flag_out": {
                 "function": "get_multi_local_extrema",
                 "module": "pygama.dsp.processors",
                 "args": [
-                    "curr",
-                    "3*bl_std",
+                    "waveform",
+                    5,
+                    10,
+                    0,
                     "vt_max_out(10)",
                     "vt_min_out(10)",
                     "n_max_out",
diff --git a/tests/dsp/test_utils.py b/tests/dsp/test_utils.py
new file mode 100644
index 000000000..b8f3cdc61
--- /dev/null
+++ b/tests/dsp/test_utils.py
@@ -0,0 +1,6 @@
+from pygama.dsp.utils import numba_defaults
+
+
+def test_numba_defaults_loading():
+    numba_defaults.cache = False
+    numba_defaults.boundscheck = True
diff --git a/tests/flow/configs/data-loader-config.json b/tests/flow/configs/data-loader-config.json
new file mode 100644
index 000000000..b4e1be070
--- /dev/null
+++ b/tests/flow/configs/data-loader-config.json
@@ -0,0 +1,21 @@
+{
+  "levels": {
+    "hit": {
+      "tiers": ["raw", "dsp", "hit"]
+    },
+    "tcm": {
+      "tiers": ["tcm"],
+      "parent": "hit",
+      "child": "evt",
+      "tcm_cols": {
+        "child_idx": "coin_idx",
+        "parent_tb": "array_id",
+        "parent_idx": "array_idx"
+      }
+    },
+    "evt": {
+      "tiers": ["evt"]
+    }
+  },
+  "channel_map": {}
+}
diff --git a/tests/flow/configs/filedb-config.json b/tests/flow/configs/filedb-config.json
new file mode 100644
index 000000000..da1026c24
--- /dev/null
+++ b/tests/flow/configs/filedb-config.json
@@ -0,0 +1,24 @@
+{
+  "data_dir": "generated/tier",
+  "tier_dirs": {
+    "raw": "/raw",
+    "dsp": "/dsp",
+    "hit": "/hit",
+    "tcm": "/tcm",
+    "evt": "/evt"
+  },
+  "file_format": {
+    "raw": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_raw.lh5",
+    "dsp": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_dsp.lh5",
+    "hit": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_hit.lh5",
+    "evt": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_evt.lh5",
+    "tcm": "/{type}/{period}/{run}/{exp}-{period}-{run}-{type}-{timestamp}-tier_tcm.lh5"
+  },
+  "table_format": {
+    "raw": "ch{ch:03d}/raw",
+    "dsp": "ch{ch:03d}/dsp",
+    "hit": "ch{ch:03d}/hit",
+    "evt": "{grp}/evt",
+    "tcm": "hardware_tcm"
+  }
+}
diff --git a/tests/flow/conftest.py b/tests/flow/conftest.py
new file mode 100644
index 000000000..c60856368
--- /dev/null
+++ b/tests/flow/conftest.py
@@ -0,0 +1,17 @@
+import json
+from pathlib import Path
+
+import pytest
+
+from pygama.flow import FileDB
+
+config_dir = Path(__file__).parent / "configs"
+
+
+@pytest.fixture(scope="module")
+def test_filedb(lgnd_test_data):
+    with open(config_dir / "filedb-config.json") as f:
+        config = json.load(f)
+
+    config["data_dir"] = lgnd_test_data.get_path("lh5/prod-ref-l200/generated/tier")
+    return FileDB(config)
diff --git a/tests/flow/test_data_loader.py b/tests/flow/test_data_loader.py
new file mode 100644
index 000000000..6c4089094
--- /dev/null
+++ b/tests/flow/test_data_loader.py
@@ -0,0 +1,107 @@
+import json
+from pathlib import Path
+
+import pandas as pd
+import pytest
+
+from pygama import lgdo
+from pygama.flow import DataLoader
+
+config_dir = Path(__file__).parent / "configs"
+
+
+@pytest.fixture(scope="function")
+def test_dl(lgnd_test_data):
+    with open(config_dir / "filedb-config.json") as f:
+        db_config = json.load(f)
+
+    db_config["data_dir"] = lgnd_test_data.get_path("lh5/prod-ref-l200/generated/tier")
+
+    return DataLoader(f"{config_dir}/data-loader-config.json", db_config)
+
+
+def test_init(test_filedb):
+    pass
+
+
+def test_simple_load(test_dl):
+    test_dl.set_files("all")
+    test_dl.set_output(columns=["timestamp"])
+    data = test_dl.load()
+
+    assert isinstance(data, lgdo.Table)
+    assert list(data.keys()) == ["hit_table", "hit_idx", "file", "timestamp"]
+
+
+def test_no_merge(test_dl):
+    test_dl.set_files("all")
+    test_dl.set_output(columns=["timestamp"], merge_files=False)
+    data = test_dl.load()
+
+    assert isinstance(data, dict)
+    assert isinstance(data[0], lgdo.Table)
+    assert len(data) == 2
+    assert list(data[0].keys()) == ["hit_table", "hit_idx", "timestamp"]
+
+
+def test_outputs(test_dl):
+    test_dl.set_files("all")
+    test_dl.set_output(
+        fmt="pd.DataFrame", columns=["timestamp", "channel", "bl_mean", "hit_par1"]
+    )
+    data = test_dl.load()
+
+    assert isinstance(data, pd.DataFrame)
+    assert list(data.keys()) == [
+        "hit_table",
+        "hit_idx",
+        "file",
+        "timestamp",
+        "channel",
+        "bl_mean",
+        "hit_par1",
+    ]
+
+
+def test_any_mode(test_dl):
+    test_dl.filedb.scan_tables_columns()
+    test_dl.set_files("all")
+    test_dl.set_cuts({"hit": "daqenergy == 634"})
+    el = test_dl.build_entry_list(tcm_level="tcm", mode="any")
+
+    assert len(el) == 42
+
+
+def test_set_files(test_dl):
+    test_dl.set_files("timestamp == '20220716T104550Z'")
+    test_dl.set_output(columns=["timestamp"], merge_files=False)
+    data = test_dl.load()
+
+    assert len(data) == 1
+
+
+def test_set_keylist(test_dl):
+    test_dl.set_files(["20220716T104550Z", "20220716T104550Z"])
+    test_dl.set_output(columns=["timestamp"], merge_files=False)
+    data = test_dl.load()
+
+    assert len(data) == 1
+
+
+def test_set_datastreams(test_dl):
+    test_dl.set_files("all")
+    test_dl.set_datastreams([1, 3, 8], "ch")
+    test_dl.set_output(columns=["channel"], merge_files=False)
+    data = test_dl.load()
+
+    assert (data[0]["hit_table"].nda == [1, 3, 8]).all()
+    assert (data[0]["channel"].nda == [1, 3, 8]).all()
+
+
+def test_set_cuts(test_dl):
+    test_dl.set_files("all")
+    test_dl.set_cuts({"hit": "card == 3"})
+    test_dl.set_output(columns=["card"])
+    data = test_dl.load()
+
+    assert (data["hit_table"].nda == [12, 13, 12, 13]).all()
diff --git a/tests/flow/test_filedb.py b/tests/flow/test_filedb.py
new file mode 100644
index 000000000..0db4dc8b2
--- /dev/null
+++ b/tests/flow/test_filedb.py
@@ -0,0 +1,165 @@
+from datetime import datetime, timezone
+
+import pytest
+from pandas.testing import assert_frame_equal
+
+from pygama.flow import FileDB
+from pygama.flow.file_db import to_datetime, to_unixtime
+
+
+def test_filedb_basics(test_filedb):
+    db = test_filedb
+
+    assert list(db.df.keys()) == [
+        "exp",
+        "period",
+        "run",
+        "timestamp",
+        "type",
+        "raw_file",
+        "dsp_file",
+        "hit_file",
+        "tcm_file",
+        "evt_file",
+        "raw_size",
+        "dsp_size",
+        "hit_size",
+        "tcm_size",
+        "evt_size",
+        "file_status",
+    ]
+
+    assert db.df.values.tolist() == [
+        [
+            "l60",
+            "p01",
+            "r014",
+            "20220716T104550Z",
+            "cal",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T104550Z-tier_raw.lh5",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T104550Z-tier_dsp.lh5",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T104550Z-tier_hit.lh5",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T104550Z-tier_tcm.lh5",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T104550Z-tier_evt.lh5",
+            2141080,
+            292032,
+            280040,
+            15728,
+            0,
+            int("0b11110", 2),
+        ],
+        [
+            "l60",
+            "p01",
+            "r014",
+            "20220716T105236Z",
+            "cal",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T105236Z-tier_raw.lh5",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T105236Z-tier_dsp.lh5",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T105236Z-tier_hit.lh5",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T105236Z-tier_tcm.lh5",
+            "/cal/p01/r014/l60-p01-r014-cal-20220716T105236Z-tier_evt.lh5",
+            2141080,
+            292032,
+            280040,
+            15728,
+            0,
+            int("0b11110", 2),
+        ],
+    ]
+
+
+def test_scan_tables_columns(test_filedb):
+    db = test_filedb
+    db.scan_tables_columns()
+
+    assert list(db.df.keys()) == [
+        "exp",
+        "period",
+        "run",
+        "timestamp",
+        "type",
+        "raw_file",
+        "dsp_file",
+        "hit_file",
+        "tcm_file",
+        "evt_file",
+        "raw_size",
+        "dsp_size",
+        "hit_size",
+        "tcm_size",
+        "evt_size",
+        "file_status",
+        "raw_tables",
+        "raw_col_idx",
+        "dsp_tables",
+        "dsp_col_idx",
+        "hit_tables",
+        "hit_col_idx",
+        "tcm_tables",
+        "tcm_col_idx",
+        "evt_tables",
+        "evt_col_idx",
+    ]
+
+    assert db.columns == [
+        [
+            "baseline",
+            "card",
+            "ch_orca",
+            "channel",
+            "crate",
+            "daqenergy",
+            "deadtime",
+            "dr_maxticks",
+            "dr_start_pps",
+            "dr_start_ticks",
+            "dr_stop_pps",
+            "dr_stop_ticks",
+            "eventnumber",
+            "fcid",
+            "numtraces",
+            "packet_id",
+            "runtime",
+            "timestamp",
+            "to_abs_mu_usec",
+            "to_dt_mu_usec",
+            "to_master_sec",
+            "to_mu_sec",
+            "to_mu_usec",
+            "to_start_sec",
+            "to_start_usec",
+            "tracelist",
+            "ts_maxticks",
+            "ts_pps",
+            "ts_ticks",
+            "waveform",
+        ],
+        [
+            "bl_mean",
+            "bl_std",
+        ],
+        ["hit_par1", "hit_par2"],
+        ["array_id", "array_idx", "cumulative_length"],
+    ]
+
+
+def test_serialization(test_filedb):
+    db = test_filedb
+    db.to_disk("/tmp/filedb.lh5", wo_mode="of")
+
+    with pytest.raises(RuntimeError):
+        db.to_disk("/tmp/filedb.lh5")
+
+    db2 = FileDB("/tmp/filedb.lh5")
+    assert_frame_equal(db.df, db2.df)
+
+
+def test_key_to_datetime():
+    assert to_datetime("20220716T105236Z") == datetime(
+        2022, 7, 16, 10, 52, 36, tzinfo=timezone.utc
+    )
+
+
+def test_key_to_unixtime():
+    assert to_unixtime("20220716T105236Z") == 1657968756
diff --git a/tests/hit/configs/basic-hit-config.json b/tests/hit/configs/basic-hit-config.json
new file mode 100644
index 000000000..0cf98137e
--- /dev/null
+++ b/tests/hit/configs/basic-hit-config.json
@@ -0,0 +1,18 @@
+{
+  "outputs": ["calE", "AoE", "A_max"],
+  "operations": {
+    "twice_trap_e_max": {
+      "expression": "2 * trapEmax"
+    },
+    "calE": {
+      "expression": "sqrt(a + b * twice_trap_e_max**2)",
+      "parameters": {
+        "a": 1.23,
+        "b": 42.69
+      }
+    },
+    "AoE": {
+      "expression": "A_max/calE"
+    }
+  }
+}
diff --git a/tests/hit/configs/spms-hit-a-config.json b/tests/hit/configs/spms-hit-a-config.json
new file mode 100644
index 000000000..d4c998348
--- /dev/null
+++ b/tests/hit/configs/spms-hit-a-config.json
@@ -0,0 +1,38 @@
+{
+  "ch0/dsp": {
+    "outputs": ["energy_in_pe"],
+    "operations": {
+      "energy_in_pe": {
+        "expression": "a + m * energies",
+        "parameters": {
+          "a": 0,
+          "m": 0
+        }
+      }
+    }
+  },
+  "ch1/dsp": {
+    "outputs": ["energy_in_pe"],
+    "operations": {
+      "energy_in_pe": {
+        "expression": "a + m * energies",
+        "parameters": {
+          "a": 1,
+          "m": 0
+        }
+      }
+    }
+  },
+  "ch2/dsp": {
+    "outputs": ["energy_in_pe"],
+    "operations": {
+      "energy_in_pe": {
+        "expression": "a + m * energies",
+        "parameters": {
+          "a": 2,
+          "m": 0
+        }
+      }
+    }
+  }
+}
diff --git a/tests/hit/configs/spms-hit-config.json b/tests/hit/configs/spms-hit-config.json
new file mode 100644
index 000000000..28366581c
--- /dev/null
+++ b/tests/hit/configs/spms-hit-config.json
@@ -0,0 +1,18 @@
+{
+  "outputs": ["energy_in_pe", "quality_cut", "trigger_pos"],
+  "operations": {
+    "energy_in_pe": {
+      "expression": "a + m * energies",
+      "parameters": {
+        "a": -0.00391,
+        "m": 0.06087
+      }
+    },
+    "quality_cut": {
+      "expression": "energy_in_pe > a",
+      "parameters": {
+        "a": 0.3
+      }
+    }
+  }
+}
diff --git a/tests/hit/configs/spms-hit-multi-config.json b/tests/hit/configs/spms-hit-multi-config.json
new file mode 100644
index 000000000..6620962f6
--- /dev/null
+++ b/tests/hit/configs/spms-hit-multi-config.json
@@ -0,0 +1,5 @@
+{
+  "ch0/dsp": "tests/hit/configs/spms-hit-config.json",
+  "ch1/dsp": "tests/hit/configs/spms-hit-config.json",
+  "ch2/dsp": "tests/hit/configs/spms-hit-config.json"
+}
diff --git a/tests/hit/test_build_hit.py b/tests/hit/test_build_hit.py
new file mode 100644
index 000000000..9a9a843f2
--- /dev/null
+++ b/tests/hit/test_build_hit.py
@@ -0,0 +1,163 @@
+import os
+from pathlib import Path
+
+import numpy as np
+import pytest
+
+import pygama.lgdo.lh5_store as store
+from pygama.hit import build_hit
+from pygama.lgdo import LH5Store, ls
+
+config_dir = Path(__file__).parent / "configs"
+
+
+def test_basics(dsp_test_file):
+    outfile = "/tmp/LDQTA_r117_20200110T105115Z_cal_geds_hit.lh5"
+
+    build_hit(
+        dsp_test_file,
+        outfile=outfile,
+        hit_config=f"{config_dir}/basic-hit-config.json",
+        wo_mode="overwrite",
+    )
+
+    assert os.path.exists(outfile)
+    assert ls(outfile, "/geds/") == ["geds/hit"]
+
+
+def test_illegal_arguments(dsp_test_file):
+    with pytest.raises(ValueError):
+        build_hit(dsp_test_file)
+
+    with pytest.raises(ValueError):
+        build_hit(
+            dsp_test_file,
+            hit_config=f"{config_dir}/basic-hit-config.json",
+            lh5_tables_config={},
+        )
+
+    with pytest.raises(ValueError):
+        build_hit(
+            dsp_test_file,
+            lh5_tables=[],
+            lh5_tables_config={},
+        )
+
+
+def test_lh5_table_configs(dsp_test_file):
+    outfile = "/tmp/LDQTA_r117_20200110T105115Z_cal_geds_hit.lh5"
+
+    lh5_tables_config = {"/geds/dsp": f"{config_dir}/basic-hit-config.json"}
+
+    build_hit(
+        dsp_test_file,
+        outfile=outfile,
+        lh5_tables_config=lh5_tables_config,
+        wo_mode="overwrite",
+    )
+
+    assert os.path.exists(outfile)
+    assert ls(outfile, "/geds/") == ["geds/hit"]
+
+    lh5_tables_config = {
+        "/geds/dsp": {
+            "outputs": ["calE", "AoE"],
+            "operations": {
+                "calE": {
+                    "expression": "sqrt(a + b * trapEmax**2)",
+                    "parameters": {"a": 1.23, "b": 42.69},
+                },
+                "AoE": {"expression": "A_max/calE"},
+            },
+        }
+    }
+
+    build_hit(
+        dsp_test_file,
+        outfile=outfile,
+        lh5_tables_config=lh5_tables_config,
+        wo_mode="overwrite",
+    )
+
+    assert os.path.exists(outfile)
+    assert ls(outfile, "/geds/") == ["geds/hit"]
+
+
+def test_outputs_specification(dsp_test_file):
+    outfile = "/tmp/LDQTA_r117_20200110T105115Z_cal_geds_hit.lh5"
+
+    build_hit(
+        dsp_test_file,
+        outfile=outfile,
+        hit_config=f"{config_dir}/basic-hit-config.json",
+        wo_mode="overwrite",
+    )
+
+    store = LH5Store()
+    obj, _ = store.read_object("/geds/hit", outfile)
+    assert list(obj.keys()) == ["calE", "AoE", "A_max"]
+
+
+def test_build_hit_spms_basic(dsp_test_file_spm):
+    out_file = "/tmp/L200-comm-20211130-phy-spms_hit.lh5"
+    build_hit(
+        dsp_test_file_spm,
+        outfile=out_file,
+        hit_config=f"{config_dir}/spms-hit-config.json",
+        wo_mode="overwrite_file",
+    )
+    assert ls(out_file) == ["ch0", "ch1", "ch2"]
+    assert ls(out_file, "ch0/") == ["ch0/hit"]
+    assert ls(out_file, "ch0/hit/") == [
+        "ch0/hit/energy_in_pe",
+        "ch0/hit/quality_cut",
+        "ch0/hit/trigger_pos",
+    ]
+
+
+def test_build_hit_spms_multiconfig(dsp_test_file_spm):
+    out_file = "/tmp/L200-comm-20211130-phy-spms_hit.lh5"
+
+    build_hit(
+        dsp_test_file_spm,
+        outfile=out_file,
+        lh5_tables_config=f"{config_dir}/spms-hit-multi-config.json",
+        wo_mode="overwrite",
+    )
+    assert ls(out_file) == ["ch0", "ch1", "ch2"]
+    assert ls(out_file, "ch0/") == ["ch0/hit"]
+    assert ls(out_file, "ch0/hit/") == [
+        "ch0/hit/energy_in_pe",
+        "ch0/hit/quality_cut",
+        "ch0/hit/trigger_pos",
+    ]
+
+
+def test_build_hit_spms_calc(dsp_test_file_spm):
+    out_file = "/tmp/L200-comm-20211130-phy-spms_hit.lh5"
+
+    build_hit(
+        dsp_test_file_spm,
+        outfile=out_file,
+        wo_mode="overwrite_file",
+        lh5_tables_config=f"{config_dir}/spms-hit-a-config.json",
+    )
+    assert ls(out_file) == ["ch0", "ch1", "ch2"]
+    assert ls(out_file, "ch0/") == ["ch0/hit"]
+    assert ls(out_file, "ch0/hit/") == ["ch0/hit/energy_in_pe"]
+
+    df0 = store.load_nda(out_file, ["energy_in_pe"], "ch0/hit/")
+    df1 = store.load_nda(out_file, ["energy_in_pe"], "ch1/hit/")
+    df2 = store.load_nda(out_file, ["energy_in_pe"], "ch2/hit/")
+
+    assert len(df0["energy_in_pe"]) == 5
+    assert len(df1["energy_in_pe"]) == 5
+    assert len(df2["energy_in_pe"]) == 5
+
+    assert len(df0["energy_in_pe"][0]) == 20
+    assert len(df1["energy_in_pe"][0]) == 20
+    assert len(df2["energy_in_pe"][0]) == 20
+
+    assert np.nanmean(df0["energy_in_pe"]) == 0
+    assert np.nanmean(df1["energy_in_pe"]) == 1
+    assert np.nanmean(df2["energy_in_pe"]) == 2
diff --git a/tests/lgdo/test_lgdo_utils.py b/tests/lgdo/test_lgdo_utils.py
index cdc7cdc07..4b316be2c 100644
--- a/tests/lgdo/test_lgdo_utils.py
+++ b/tests/lgdo/test_lgdo_utils.py
@@ -1,4 +1,7 @@
+import os
+
 import numpy as np
+import pytest
 
 import pygama.lgdo.lgdo_utils as lgdo_utils
 
@@ -43,3 +46,28 @@ def test_parse_datatype():
     for string, dt_tuple in datatypes:
         pd_dt_tuple = lgdo_utils.parse_datatype(string)
         assert pd_dt_tuple == dt_tuple
+
+
+def test_expand_path(lgnd_test_data):
+    files = [
+        lgnd_test_data.get_path(
+            "lh5/prod-ref-l200/generated/tier/dsp/cal/p01/r014/l60-p01-r014-cal-20220716T104550Z-tier_dsp.lh5"
+        ),
+        lgnd_test_data.get_path(
+            "lh5/prod-ref-l200/generated/tier/dsp/cal/p01/r014/l60-p01-r014-cal-20220716T105236Z-tier_dsp.lh5"
+        ),
+    ]
+    base_dir = os.path.dirname(files[0])
+
+    assert lgdo_utils.expand_path(f"{base_dir}/*20220716T104550Z*") == files[0]
+
+    # Should fail if file not found
+    with pytest.raises(FileNotFoundError):
+        lgdo_utils.expand_path(f"{base_dir}/not_a_real_file.lh5")
+
+    # Should fail if multiple files found
+    with pytest.raises(FileNotFoundError):
+        lgdo_utils.expand_path(f"{base_dir}/*.lh5")
+
+    # Check if it finds a list of files correctly
+    assert sorted(lgdo_utils.expand_path(f"{base_dir}/*.lh5", True)) == sorted(files)
diff --git a/tests/lgdo/test_lh5_iterator.py b/tests/lgdo/test_lh5_iterator.py
new file mode 100644
index 000000000..61c16f585
--- /dev/null
+++ b/tests/lgdo/test_lh5_iterator.py
@@ -0,0 +1,74 @@
+import numpy as np
+import pytest
+
+import pygama.lgdo as lgdo
+from pygama.lgdo.lh5_store import LH5Iterator
+
+
+@pytest.fixture(scope="module")
+def lgnd_file(lgnd_test_data):
+    return lgnd_test_data.get_path("lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5")
+
+
+def test_basics(lgnd_file):
+    lh5_it = LH5Iterator(
+        lgnd_file,
+        "/geds/raw",
+        entry_list=range(100),
+        field_mask=["baseline"],
+        buffer_len=5,
+    )
+
+    lh5_obj, n_rows = lh5_it.read(4)
+    assert n_rows == 5
+    assert isinstance(lh5_obj, lgdo.Table)
+    assert list(lh5_obj.keys()) == ["baseline"]
+    assert (
+        lh5_obj["baseline"].nda == np.array([14353, 14254, 14525, 11656, 13576])
+    ).all()
+
+    for lh5_obj, entry, n_rows in lh5_it:
+        assert len(lh5_obj) == 5
+        assert n_rows == 5
+        assert entry % 5 == 0
+
+
+def test_errors(lgnd_file):
+    with pytest.raises(RuntimeError):
+        LH5Iterator("non-existent-file.lh5", "random-group")
+
+    with pytest.raises(ValueError):
+        LH5Iterator(1, 2)
+
+
+def test_lgnd_waveform_table_fancy_idx(lgnd_file):
+    lh5_it = LH5Iterator(
+        lgnd_file,
+        "geds/raw/waveform",
+        entry_list=[
+            7,
+            9,
+            25,
+            27,
+            33,
+            38,
+            46,
+            52,
+            57,
+            59,
+            67,
+            71,
+            72,
+            82,
+            90,
+            92,
+            93,
+            94,
+            97,
+        ],
+        buffer_len=5,
+    )
+
+    lh5_obj, n_rows = lh5_it.read(0)
+    assert isinstance(lh5_obj, lgdo.WaveformTable)
+    assert len(lh5_obj) == 5
diff --git a/tests/lgdo/test_lh5_store.py b/tests/lgdo/test_lh5_store.py
index 5542fe500..1ff6b712e 100644
--- a/tests/lgdo/test_lh5_store.py
+++ b/tests/lgdo/test_lh5_store.py
@@ -5,7 +5,7 @@
 
 import pygama.lgdo as lgdo
 import pygama.lgdo.lh5_store as lh5
-from pygama.lgdo.lh5_store import LH5Iterator, LH5Store
+from pygama.lgdo.lh5_store import LH5Store
 
 
 @pytest.fixture(scope="module")
@@ -39,6 +39,12 @@ def test_gimme_group(lgnd_file):
     assert isinstance(g, h5py.Group)
 
 
+def test_show(lgnd_file):
+    lh5.show(lgnd_file)
+    lh5.show(lgnd_file, "/geds/raw")
+    lh5.show(lgnd_file, "geds/raw")
+
+
 def test_ls(lgnd_file):
     assert lh5.ls(lgnd_file) == ["geds"]
     assert lh5.ls(lgnd_file, "/*/raw") == ["geds/raw"]
@@ -121,6 +127,14 @@ def lh5_file():
         wo_mode="overwrite_file",
     )
 
+    store.write_object(
+        struct,
+        "struct_full",
+        "/tmp/tmp-pygama-lgdo-types.lh5",
+        group="/data",
+        wo_mode="append",
+    )
+
     return "/tmp/tmp-pygama-lgdo-types.lh5"
 
 
@@ -144,12 +158,22 @@ def test_read_array(lh5_file):
     assert n_rows == 3
 
 
+def test_read_array_fancy_idx(lh5_file):
+    store = LH5Store()
+    lh5_obj, n_rows = store.read_object(
+        "/data/struct_full/array", lh5_file, idx=[0, 3, 4]
+    )
+    assert isinstance(lh5_obj, lgdo.Array)
+    assert (lh5_obj.nda == np.array([1, 4, 5])).all()
+    assert n_rows == 3
+
+
 def test_read_vov(lh5_file):
     store = LH5Store()
     lh5_obj, n_rows = store.read_object("/data/struct/vov", lh5_file)
     assert isinstance(lh5_obj, lgdo.VectorOfVectors)
 
-    desired = [np.array([3, 4, 5, 2]), np.array([]), np.array([])]
+    desired = [np.array([3, 4, 5]), np.array([2]), np.array([4, 8, 9, 7])]
 
     for i in range(len(desired)):
         assert (desired[i] == list(lh5_obj)[i]).all()
@@ -157,6 +181,19 @@ def test_read_vov(lh5_file):
     assert n_rows == 3
 
 
+def test_read_vov_fancy_idx(lh5_file):
+    store = LH5Store()
+    lh5_obj, n_rows = store.read_object("/data/struct_full/vov", lh5_file, idx=[0, 2])
+    assert isinstance(lh5_obj, lgdo.VectorOfVectors)
+
+    desired = [np.array([1, 2]), np.array([2])]
+
+    for i in range(len(desired)):
+        assert (desired[i] == list(lh5_obj)[i]).all()
+
+    assert n_rows == 2
+
+
 def test_read_aoesa(lh5_file):
     store = LH5Store()
     lh5_obj, n_rows = store.read_object("/data/struct/aoesa", lh5_file)
@@ -174,6 +211,30 @@ def test_read_table(lh5_file):
     assert n_rows == 6
 
 
+def test_read_with_field_mask(lh5_file):
+    store = LH5Store()
+
+    lh5_obj, n_rows = store.read_object(
+        "/data/struct_full", lh5_file, field_mask=["array"]
+    )
+    assert list(lh5_obj.keys()) == ["array"]
+
+    lh5_obj, n_rows = store.read_object(
+        "/data/struct_full", lh5_file, field_mask=("array", "table")
+    )
+    assert list(lh5_obj.keys()) == ["array", "table"]
+
+    lh5_obj, n_rows = store.read_object(
+        "/data/struct_full", lh5_file, field_mask={"array": True}
+    )
+    assert list(lh5_obj.keys()) == ["array"]
+
+    lh5_obj, n_rows = store.read_object(
+        "/data/struct_full", lh5_file, field_mask={"vov": False}
+    )
+    assert list(lh5_obj.keys()) == ["scalar", "array", "aoesa", "table"]
+
+
 def test_read_lgnd_array(lgnd_file):
     store = LH5Store()
 
@@ -186,6 +247,40 @@ def test_read_lgnd_array(lgnd_file):
     assert isinstance(lh5_obj, lgdo.ArrayOfEqualSizedArrays)
 
 
+def test_read_lgnd_array_fancy_idx(lgnd_file):
+    store = LH5Store()
+
+    lh5_obj, n_rows = store.read_object(
+        "/geds/raw/baseline", lgnd_file, idx=[2, 4, 6, 9, 11, 16, 68]
+    )
+    assert isinstance(lh5_obj, lgdo.Array)
+    assert n_rows == 7
+    assert len(lh5_obj) == 7
+    assert (lh5_obj.nda == [13508, 14353, 14525, 14341, 15079, 11675, 13995]).all()
+
+
+def test_read_lgnd_vov(lgnd_file):
+    store = LH5Store()
+
+    lh5_obj, n_rows = store.read_object("/geds/raw/tracelist", lgnd_file)
+    assert isinstance(lh5_obj, lgdo.VectorOfVectors)
+    assert n_rows == 100
+    assert len(lh5_obj) == 100
+
+
+def test_read_lgnd_vov_fancy_idx(lgnd_file):
+    store = LH5Store()
+
+    lh5_obj, n_rows = store.read_object(
+        "/geds/raw/tracelist", lgnd_file, idx=[2, 4, 6, 9, 11, 16, 68]
+    )
+    assert isinstance(lh5_obj, lgdo.VectorOfVectors)
+    assert n_rows == 7
+    assert len(lh5_obj) == 7
+    assert (lh5_obj.cumulative_length.nda == [1, 2, 3, 4, 5, 6, 7]).all()
+    assert (lh5_obj.flattened_data.nda == [40, 60, 64, 60, 64, 28, 60]).all()
+
+
 def test_read_array_concatenation(lgnd_file):
     store = LH5Store()
     lh5_obj, n_rows = store.read_object("/geds/raw/baseline", [lgnd_file, lgnd_file])
@@ -213,24 +308,13 @@ def test_read_lgnd_waveform_table(lgnd_file):
     assert len(lh5_obj) == 10
 
 
-def test_lh5_iterator(lgnd_file):
-    lh5_it = LH5Iterator(
+def test_read_lgnd_waveform_table_fancy_idx(lgnd_file):
+    store = LH5Store()
+
+    lh5_obj, n_rows = store.read_object(
+        "/geds/raw/waveform",
         lgnd_file,
-        "/geds/raw",
-        entry_list=range(100),
-        field_mask=["baseline"],
-        buffer_len=5,
+        idx=[7, 9, 25, 27, 33, 38, 46, 52, 57, 59, 67, 71, 72, 82, 90, 92, 93, 94, 97],
     )
-
-    lh5_obj, n_rows = lh5_it.read(4)
-    assert n_rows == 5
-    assert isinstance(lh5_obj, lgdo.Table)
-    assert list(lh5_obj.keys()) == ["baseline"]
-    assert (
-        lh5_obj["baseline"].nda == np.array([14353, 14254, 14525, 11656, 13576])
-    ).all()
-
-    for lh5_obj, entry, n_rows in lh5_it:
-        assert len(lh5_obj) == 5
-        assert n_rows == 5
-        assert entry % 5 == 0
+    assert isinstance(lh5_obj, lgdo.WaveformTable)
+    assert len(lh5_obj) == 19
diff --git a/tests/lgdo/test_representations.py b/tests/lgdo/test_representations.py
new file mode 100644
index 000000000..18cb6af0f
--- /dev/null
+++ b/tests/lgdo/test_representations.py
@@ -0,0 +1,62 @@
+import numpy as np
+
+from pygama import lgdo
+
+
+def test_representations():
+    objs = {
+        "Scalar": lgdo.Scalar("test", attrs={"unit": "ns"}),
+        "Array1": lgdo.Array(np.random.rand(3), attrs={"unit": "ns"}),
+        "Array2": lgdo.Array(np.random.rand(10), attrs={"unit": "ns"}),
+        "Array3": lgdo.Array(np.random.rand(100), attrs={"unit": "ns"}),
+        "ArrayOfEqualSizedArrays": lgdo.ArrayOfEqualSizedArrays(
+            nda=np.random.rand(10, 100), attrs={"unit": "ns"}
+        ),
+        "Struct": lgdo.Struct(
+            {
+                "first": lgdo.Array(np.random.rand(100), attrs={"unit": "ns"}),
+                "second": lgdo.Scalar(3.45, attrs={"unit": "ns"}),
+                "third": lgdo.Array(np.random.rand(3), attrs={"unit": "ns"}),
+                "fourth": lgdo.ArrayOfEqualSizedArrays(
+                    nda=np.random.rand(10, 100), attrs={"unit": "ns"}
+                ),
+            },
+            attrs={"unit": "ns"},
+        ),
+        "VectorOfVectors": lgdo.VectorOfVectors(
+            flattened_data=lgdo.Array(np.random.rand(1000)),
+            cumulative_length=lgdo.Array(np.array([5, 12, 34, 49, 150])),
+            attrs={"unit": "ns"},
+        ),
+        "Table": lgdo.Table(
+            col_dict={
+                "first": lgdo.Array(np.random.rand(100), attrs={"unit": "ns"}),
+                "second": lgdo.Array(np.random.rand(100)),
+                "third": lgdo.Array(np.random.rand(100), attrs={"unit": "ns"}),
+            },
+            attrs={"greeting": "ciao"},
+        ),
+        "WaveformTable1": lgdo.WaveformTable(
+            values=lgdo.VectorOfVectors(
+                flattened_data=lgdo.Array(np.random.rand(1000)),
+                cumulative_length=lgdo.Array(np.array([5, 12, 74, 230])),
+            ),
+            attrs={"greeting": "ciao"},
+        ),
+        "WaveformTable2": lgdo.WaveformTable(
+            values=lgdo.ArrayOfEqualSizedArrays(nda=np.random.rand(10, 1000)),
+            attrs={"greeting": "ciao"},
+        ),
+        "WaveformTable3": lgdo.WaveformTable(
+            values=lgdo.ArrayOfEqualSizedArrays(nda=np.random.rand(10, 100)),
+            attrs={"greeting": "ciao"},
+        ),
+    }
+
+    for k, it in objs.items():
+        print(f">>> {k}")  # noqa: T201
+        print(repr(it))  # noqa: T201
+        print()  # noqa: T201
+        print(f">>> print({k})")  # noqa: T201
+        print(it)  # noqa: T201
+        print()  # noqa: T201
diff --git a/tests/lgdo/test_struct.py b/tests/lgdo/test_struct.py
index e7f1c85ec..2f7d6fb89 100644
--- a/tests/lgdo/test_struct.py
+++ b/tests/lgdo/test_struct.py
@@ -34,3 +34,15 @@ def test_add_field():
 
     struct.add_field("array1", lgdo.Array(shape=(700, 21), dtype="f", fill_val=2))
     assert struct.attrs["datatype"] == "struct{scalar1,array1}"
+
+
+def test_remove_field():
+    struct = lgdo.Struct()
+    struct.add_field("scalar1", lgdo.Scalar(value=10))
+    struct.add_field("array1", lgdo.Array(shape=(10), fill_val=0))
+
+    struct.remove_field("scalar1")
+    assert list(struct.keys()) == ["array1"]
+
+    struct.remove_field("array1", delete=True)
+    assert list(struct.keys()) == []
diff --git a/tests/lgdo/test_table.py b/tests/lgdo/test_table.py
index 4323b19b3..3683f8d96 100644
--- a/tests/lgdo/test_table.py
+++ b/tests/lgdo/test_table.py
@@ -87,6 +87,30 @@ def test_join():
 def test_get_dataframe():
     tbl = Table(3)
     tbl.add_column("a", lgdo.Array(np.array([1, 2, 3])))
+    tbl.add_column("b", lgdo.Array(np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])))
+    tbl.add_column(
+        "c",
+        lgdo.VectorOfVectors(
+            flattened_data=lgdo.Array(np.array([0, 1, 2, 3, 4, 5, 6])),
+            cumulative_length=lgdo.Array(np.array([3, 4, 7])),
+        ),
+    )
     df = tbl.get_dataframe()
     assert isinstance(df, pd.DataFrame)
-    assert df.keys().all() == "a"
+    assert list(df.keys()) == ["a", "b", "c"]
+
+
+def test_remove_column():
+    col_dict = {
+        "a": lgdo.Array(nda=np.array([1, 2, 3, 4])),
+        "b": lgdo.Array(nda=np.array([5, 6, 7, 8])),
+        "c": lgdo.Array(nda=np.array([9, 10, 11, 12])),
+    }
+
+    tbl = Table(col_dict=col_dict)
+
+    tbl.remove_column("a")
+    assert list(tbl.keys()) == ["b", "c"]
+
+    tbl.remove_column("c")
+    assert list(tbl.keys()) == ["b"]
diff --git a/tests/lgdo/test_table_eval.py b/tests/lgdo/test_table_eval.py
new file mode 100644
index 000000000..94888a3a9
--- /dev/null
+++ b/tests/lgdo/test_table_eval.py
@@ -0,0 +1,96 @@
+import numpy as np
+
+from pygama.lgdo import Array, ArrayOfEqualSizedArrays, Table
+
+
+def test_eval_dependency():
+    obj = Table(
+        col_dict={
+            "a": Array(nda=np.array([1, 2, 3, 4], dtype=np.float32)),
+            "b": Array(nda=np.array([5, 6, 7, 8], dtype=np.float32)),
+            "c": ArrayOfEqualSizedArrays(
+                nda=np.array(
+                    [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]],
+                    dtype=np.float32,
+                )
+            ),
+            "d": ArrayOfEqualSizedArrays(
+                nda=np.array(
+                    [
+                        [21, 22, 23, 24],
+                        [25, 26, 27, 8],
+                        [29, 210, 211, 212],
+                        [213, 214, 215, 216],
+                    ],
+                    dtype=np.float32,
+                )
+            ),
+        }
+    )
+
+    expr_config = {
+        "O1": {"expression": "p1 + p2 * a**2", "parameters": {"p1": 2, "p2": 3}},
+        "O2": {"expression": "O1 - b"},
+        "O3": {"expression": "p1 + p2 * c", "parameters": {"p1": 2, "p2": 3}},
+        "O4": {"expression": "O3 - d", "parameters": {"p1": 2, "p2": 3}},
+        "O5": {"expression": "sum(c,axis=1)"},
+        "O6": {"expression": "a>p1", "parameters": {"p1": 2}},
+        "O7": {"expression": "c>p1", "parameters": {"p1": 2}},
+    }
+
+    out_tbl = obj.eval(expr_config)
+    assert list(out_tbl.keys()) == ["O1", "O2", "O3", "O4", "O5", "O6", "O7"]
+    assert (out_tbl["O1"].nda == [5, 14, 29, 50]).all()
+    assert (out_tbl["O2"].nda == [0, 8, 22, 42]).all()
+    assert (
+        out_tbl["O3"].nda
+        == [[5, 8, 11, 14], [17, 20, 23, 26], [29, 32, 35, 38], [41, 44, 47, 50]]
+    ).all()
+    assert (
+        out_tbl["O4"].nda
+        == [
+            [-16.0, -14.0, -12.0, -10.0],
+            [-8.0, -6.0, -4.0, 18.0],
+            [0.0, -178.0, -176.0, -174.0],
+            [-172.0, -170.0, -168.0, -166.0],
+        ]
+    ).all()
+    assert (out_tbl["O5"].nda == [10.0, 26.0, 42.0, 58.0]).all()
+    assert (out_tbl["O6"].nda == [False, False, True, True]).all()
+    assert (
+        out_tbl["O7"].nda
+        == [
+            [False, False, True, True],
+            [True, True, True, True],
+            [True, True, True, True],
+            [True, True, True, True],
+        ]
+    ).all()
+
+
+def test_eval_math_functions():
+    obj = Table(
+        col_dict={
+            "a": Array(nda=np.array([1, 2, 3, 4], dtype=np.float32)),
+            "b": Array(nda=np.array([5, 6, 7, 8], dtype=np.float32)),
+            "c": ArrayOfEqualSizedArrays(
+                nda=np.array(
+                    [[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]],
+                    dtype=np.float32,
+                )
+            ),
+        }
+    )
+
+    expr_config = {
+        "O1": {"expression": "exp(log(a))"},
+        "O2": {"expression": "exp(log(c))"},
+    }
+
+    out_tbl = obj.eval(expr_config)
+    assert list(out_tbl.keys()) == ["O1", "O2"]
+    assert (out_tbl["O1"].nda == [1, 2, 3, 4]).all()
+    assert (
+        out_tbl["O2"].nda
+        == np.array([[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]])
+    ).all()
diff --git a/tests/lgdo/test_vectorofvectors.py b/tests/lgdo/test_vectorofvectors.py
index b6b048d7a..4e860baff 100644
--- a/tests/lgdo/test_vectorofvectors.py
+++ b/tests/lgdo/test_vectorofvectors.py
@@ -44,6 +44,21 @@ def test_resize(lgdo_vov):
     assert len(lgdo_vov.cumulative_length) == 3
 
 
+def test_aoesa(lgdo_vov):
+    arr = lgdo_vov.to_aoesa()
+    desired = np.array(
+        [
+            [1, 2, np.nan, np.nan],
+            [3, 4, 5, np.nan],
+            [2, np.nan, np.nan, np.nan],
+            [4, 8, 9, 7],
+            [5, 3, 1, np.nan],
+        ]
+    )
+    assert isinstance(arr, lgdo.ArrayOfEqualSizedArrays)
+    assert np.array_equal(arr.nda, desired, True)
+
+
 def test_set_vector(lgdo_vov):
     lgdo_vov.set_vector(0, np.zeros(2))
 
diff --git a/tests/pargen/test_energy_optimization.py b/tests/pargen/test_energy_optimization.py
new file mode 100644
index 000000000..b9bc27650
--- /dev/null
+++ b/tests/pargen/test_energy_optimization.py
@@ -0,0 +1,12 @@
+import scipy
+
+from pygama.pargen import energy_optimisation  # noqa: F401
+
+
+def test_import():
+    pass
+
+
+def test_scipy_version():
+    assert scipy.__version__ != ""
+    assert scipy.__version__ is not None
diff --git a/tests/raw/test_build_raw.py b/tests/raw/test_build_raw.py
index cb46f6f13..fe7f600ff 100644
--- a/tests/raw/test_build_raw.py
+++ b/tests/raw/test_build_raw.py
@@ -140,3 +140,21 @@ def test_build_raw_overwrite(lgnd_test_data):
         build_raw(
             in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio")
         )
+
+
+def test_build_raw_orca_sis3316(lgnd_test_data):
+    out_file = "/tmp/coherent-run1141-bkg.lh5"
+    out_spec = {
+        "ORSIS3316WaveformDecoder": {
+            "Card1": {"key_list": [48], "out_stream": out_file}
+        }
+    }
+
+    build_raw(
+        in_stream=lgnd_test_data.get_path("orca/sis3316/coherent-run1141-bkg.orca"),
+        out_spec=out_spec,
+        n_max=10,
+        overwrite=True,
+    )
+
+    assert os.path.exists(out_file)
diff --git a/tests/vis/test_waveform_browser.py b/tests/vis/test_waveform_browser.py
index 1ccd57aae..ce2841605 100644
--- a/tests/vis/test_waveform_browser.py
+++ b/tests/vis/test_waveform_browser.py
@@ -5,15 +5,14 @@
 config_dir = Path(__file__).parent / "configs"
 
 
-def test_waveform_browser(lgnd_test_data):
-
+def test_basics(lgnd_test_data):
     wb = WaveformBrowser(
         lgnd_test_data.get_path("lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5"),
         "/geds/raw",
         dsp_config=f"{config_dir}/hpge-dsp-config.json",
         lines=["wf_blsub", "wf_trap", "trapEmax"],
         legend=["waveform", "trapezoidal", "energy = {trapEmax:0.1f}"],
-        styles="seaborn",
+        styles="seaborn-v0.8",
         n_drawn=2,
         x_lim=("20*us", "60*us"),
         x_unit="us",
@@ -22,3 +21,33 @@ def test_waveform_browser(lgnd_test_data):
     wb.draw_next()
     wb.draw_entry(24)
     wb.draw_entry((2, 24))
+
+
+def test_entry_mask(lgnd_test_data):
+    wb = WaveformBrowser(
+        lgnd_test_data.get_path("lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5"),
+        "/geds/raw",
+        entry_list=[
+            7,
+            9,
+            25,
+            27,
+            33,
+            38,
+            46,
+            52,
+            57,
+            59,
+            67,
+            71,
+            72,
+            82,
+            90,
+            92,
+            93,
+            94,
+            97,
+        ],
+    )
+
+    wb.draw_next()