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

Issue:
[docs] a couple more website URL fixes



Discussion / hints:

Fix patch:
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index b43f3dee7..cd9420d72 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,6 +1,6 @@
-Before submitting a pull request, please make sure you've read and understood the pygama developer's guide: https://legend-exp.github.io/pygama/main/developer.html. In particular, do not forget to:
+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:
 
-- [ ] Follow our **coding conventions**
+- [ ] Conform to our **coding conventions**
 - [ ] Update existing or add new **tests**
 - [ ] Update existing or add new **documentation**
-- [ ] Address any issue reported by GitHub bots
+- [ ] Address any issue reported by GitHub checks
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index fe8432efc..d4c5de7e2 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -54,7 +54,7 @@ jobs:
       - name: Upload Coverage to codecov.io
         uses: codecov/codecov-action@v3
 
-  deploy-docs:
+  test-docs:
     name: Build documentation
     needs: build-and-test
     runs-on: ubuntu-latest
@@ -74,15 +74,3 @@ jobs:
           cd docs
           make clean
           make
-      - name: Build docs for selected versions (GitHub pages)
-        run: |
-          cd docs
-          make clean
-          make allver-info
-          make allver
-      - if: ${{ github.event_name == 'push' }}
-        name: Deploy to GitHub pages
-        uses: JamesIves/github-pages-deploy-action@v4.3.4
-        with:
-          branch: gh-pages
-          folder: docs/build/allver/html
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f60120ca5..598761b49 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
+exclude: ^(attic|src/pygama/math|src/pygama/pargen|src/pygama/flow/datagroup.py)
 repos:
 - repo: https://github.com/pre-commit/pre-commit-hooks
   rev: "v4.3.0"
@@ -24,14 +24,8 @@ repos:
   - id: requirements-txt-fixer
   - id: trailing-whitespace
 
-- repo: https://github.com/Carreau/velin.git
-  rev: "0.0.12"
-  hooks:
-    - id: velin
-      args: ["--check", "--write"]
-
 - repo: https://github.com/asottile/setup-cfg-fmt
-  rev: "v1.20.1"
+  rev: "v2.0.0"
   hooks:
   - id: setup-cfg-fmt
 
@@ -41,27 +35,46 @@ repos:
   - id: isort
 
 - repo: https://github.com/asottile/pyupgrade
-  rev: "v2.34.0"
+  rev: "v2.37.3"
   hooks:
   - id: pyupgrade
-    args: ["--py36-plus"]
+    args: ["--py38-plus"]
 
-# - repo: https://github.com/psf/black
-#   rev: "22.3.0"
-#   hooks:
-#   - id: black
+- repo: https://github.com/psf/black
+  rev: "22.6.0"
+  hooks:
+  - id: black
 
-# - repo: https://github.com/PyCQA/flake8
-#   rev: "4.0.1"
+# - repo: https://github.com/pre-commit/mirrors-mypy
+#   rev: "v0.961"
 #   hooks:
-#   - id: flake8
-#     additional_dependencies: [flake8-bugbear, flake8-print]
+#     - id: mypy
+#       files: src
+#       args: []
+
+- repo: https://github.com/hadialqattan/pycln
+  rev: "v2.1.1"
+  hooks:
+    - id: pycln
+      args: [--all]
+
+- repo: https://github.com/PyCQA/flake8
+  rev: "5.0.2"
+  hooks:
+  - id: flake8
+    additional_dependencies: [
+      flake8-bugbear,
+      flake8-print,
+      # flake8-docstrings,
+      pep8-naming
+    ]
+    # args: ["--docstring-convention", "numpy"]
 
 - repo: https://github.com/kynan/nbstripout
-  rev: "0.5.0"
+  rev: "0.6.0"
   hooks:
     - id: nbstripout
-      args: ["--strip-empty-cells",
+      args: ["--drop-empty-cells",
              "--extra-keys", "metadata.kernelspec metadata.language_info"]
 
 - repo: https://github.com/mgedmin/check-manifest
@@ -84,6 +97,13 @@ repos:
 - repo: https://github.com/pre-commit/pygrep-hooks
   rev: "v1.9.0"
   hooks:
+  - id: python-no-log-warn
   - id: rst-backticks
   - id: rst-directive-colons
   - id: rst-inline-touching-normal
+
+- repo: https://github.com/pre-commit/mirrors-prettier
+  rev: "v2.7.1"
+  hooks:
+    - id: prettier
+      types_or: [json]
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 000000000..56157945d
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,26 @@
+version: 2
+
+sphinx:
+  configuration: docs/source/conf.py
+
+build:
+  os: "ubuntu-22.04"
+  tools:
+    python: "3.10"
+  jobs:
+    pre_build:
+      - rm -rf docs/source/generated
+      - sphinx-apidoc
+        --private
+        --module-first
+        --force
+        --output-dir docs/source/generated
+        src/pygama
+        src/pygama/_version.py
+
+python:
+  install:
+    - method: pip
+      path: .
+      extra_requirements:
+        - docs
diff --git a/README.md b/README.md
index 6fa2c2360..fa76c6f9e 100644
--- a/README.md
+++ b/README.md
@@ -3,11 +3,12 @@
 ![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/legend-exp/pygama?logo=git)
 [![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)
-[![Documentation](https://img.shields.io/badge/documentation-online-purple?logo=readthedocs)](https://legend-exp.github.io/pygama)
+[![Read the Docs](https://img.shields.io/readthedocs/pygama?logo=readthedocs)](https://pygama.readthedocs.io)
 
 *pygama* is a Python package for:
 
@@ -16,4 +17,4 @@
 * optimizing DSP routines and tuning associated analysis parameters
 * generating and selecting high-level event data for further analysis
 
-Check out the [online documentation](https://legend-exp.github.io/pygama).
+Check out the [online documentation](https://pygama.readthedocs.io).
diff --git a/attic/pygama/raw/orca_digitizers.py b/attic/pygama/raw/orca_digitizers.py
new file mode 100644
index 000000000..465594727
--- /dev/null
+++ b/attic/pygama/raw/orca_digitizers.py
@@ -0,0 +1,648 @@
+import sys
+
+import numpy as np
+
+from pygama.lgdo import Table
+
+from .orca_base import OrcaDecoder, get_ccc
+
+
+class ORCAStruck3302(OrcaDecoder):
+    """
+    ORCA decoder for Struck 3302 digitizer data
+    """
+    def __init__(self, *args, **kwargs):
+
+        self.decoder_name = 'ORSIS3302DecoderForEnergy'
+        self.orca_class_name = 'ORSIS3302Model'
+
+        self.decoded_values_template = {
+            'packet_id': {
+               'dtype': 'uint32',
+             },
+            'ievt': {
+              'dtype': 'uint32',
+            },
+            'energy': {
+              'dtype': 'uint32',
+              'units': 'adc',
+            },
+            'energy_first': {
+              'dtype': 'uint32',
+            },
+            'timestamp': {
+              'dtype': 'uint64',
+              'units': 'clock_ticks',
+            },
+            'crate': {
+              'dtype': 'uint8',
+            },
+            'card': {
+              'dtype': 'uint8',
+            },
+            'channel': {
+              'dtype': 'uint8',
+            },
+            'waveform': {
+              'dtype': 'uint16',
+              'datatype': 'waveform',
+              'length': 65532, # max value. override this before initializing buffers to save RAM
+              'sample_period': 10, # override if a different clock rate is used
+              'sample_period_units': 'ns',
+              'units': 'adc',
+            },
+        }
+
+        super().__init__(*args, **kwargs) # also initializes the garbage df
+
+        self.decoded_values = {}
+        self.ievt = 0
+        self.skipped_channels = {}
+        # self.enabled_cccs = []
+
+
+    def get_decoded_values(self, channel=None):
+        if channel is None:
+            dec_vals_list = self.decoded_values.items()
+            if len(dec_vals_list) == 0:
+                print("ORSIS3302Model: Error: decoded_values not built yet!")
+                return None
+            return list(dec_vals_list)[0][1] # Get first thing we find
+        if channel in self.decoded_values: return self.decoded_values[channel]
+        print("ORSIS3302Model: Error: No decoded values for channel", channel)
+        return None
+
+
+    def set_object_info(self, object_info):
+        self.object_info = object_info
+
+        # parse object_info for important info
+        for card_dict in self.object_info:
+            crate = card_dict['Crate']
+            card = card_dict['Card']
+
+            int_enabled_mask = card_dict['internalTriggerEnabledMask']
+            ext_enabled_mask = card_dict['externalTriggerEnabledMask']
+            enabled_mask = int_enabled_mask | ext_enabled_mask
+            for channel in range(10):
+                # only care about enabled channels
+                if not ((enabled_mask >> channel) & 0x1): continue
+
+                ccc = get_ccc(crate, card, channel)
+                # save list of enabled channels
+                #self.enabled_cccs.append(ccc)
+
+                self.decoded_values[ccc] = copy.deepcopy(self.decoded_values_template)
+                sd = self.decoded_values[ccc] # alias
+
+                # get trace length(s). Should all be the same until
+                # multi-buffer mode is implemented AND each channel has its
+                # own buffer
+                trace_length = card_dict['sampleLengths'][int(channel/2)]
+                if trace_length <= 0 or trace_length > 2**16:
+                    print('SIS3316ORCADecoder Error: invalid trace_length', trace_length)
+                    sys.exit()
+                self.decoded_values[ccc]['waveform']['length'] = trace_length
+
+
+    def max_n_rows_per_packet(self):
+        return 1
+
+
+    def decode_packet(self, packet, lh5_tables, packet_id, header_dict, verbose=False):
+        """
+        see README for the 32-bit data word diagram
+        """
+        # interpret the raw event data into numpy arrays of 16 and 32 bit ints
+        # does not copy data. p32 and p16 are read-only
+        p32 = np.frombuffer(packet, dtype=np.uint32)
+        p16 = np.frombuffer(packet, dtype=np.uint16)
+
+        # read the crate/card/channel first
+        crate = (p32[0] >> 21) & 0xF
+        card = (p32[0] >> 16) & 0x1F
+        channel = (p32[0] >> 8) & 0xFF
+        ccc = get_ccc(crate, card, channel)
+
+        # aliases for brevity
+        tb = lh5_tables
+        # if the first key is an int, then there are different tables for
+        # each channel.
+        if isinstance(list(tb.keys())[0], int):
+            if ccc not in lh5_tables:
+                if ccc not in self.skipped_channels:
+                    self.skipped_channels[ccc] = 0
+                self.skipped_channels[ccc] += 1
+                return
+            tb = lh5_tables[ccc]
+        ii = tb.loc
+
+        # store packet id
+        tb['packet_id'].nda[ii] = packet_id
+
+        # read the rest of the record
+        n_lost_msb = (p32[0] >> 25) & 0x7F
+        n_lost_lsb = (p32[0] >> 2) & 0x7F
+        n_lost_records = (n_lost_msb << 7) + n_lost_lsb
+        tb['crate'].nda[ii] = (p32[0] >> 21) & 0xF
+        tb['card'].nda[ii] = (p32[0] >> 16) & 0x1F
+        tb['channel'].nda[ii] = (p32[0] >> 8) & 0xFF
+        buffer_wrap = p32[0] & 0x1
+        wf_length32 = p32[1]
+        ene_wf_length32 = p32[2]
+        evt_header_id = p32[3] & 0xFF
+        tb['timestamp'].nda[ii] = p32[4] + ((p32[3] & 0xFFFF0000) << 16)
+        last_word = p32[-1]
+
+        # get the footer
+        tb['energy'].nda[ii] = p32[-4]
+        tb['energy_first'].nda[ii] = p32[-3]
+        extra_flags = p32[-2]
+
+        # compute expected and actual array dimensions
+        wf_length16 = 2 * wf_length32
+        orca_helper_length16 = 2
+        sis_header_length16 = 12 if buffer_wrap else 8
+        header_length16 = orca_helper_length16 + sis_header_length16
+        ene_wf_length16 = 2 * ene_wf_length32
+        footer_length16 = 8
+        expected_wf_length = len(p16) - orca_helper_length16 - sis_header_length16 - \
+            footer_length16 - ene_wf_length16
+
+        # error check: waveform size must match expectations
+        if wf_length16 != expected_wf_length or last_word != 0xdeadbeef:
+            print(len(p16), orca_helper_length16, sis_header_length16,
+                  footer_length16)
+            print("ERROR: Waveform size %d doesn't match expected size %d." %
+                  (wf_length16, expected_wf_length))
+            print("       The Last Word (should be 0xdeadbeef):",
+                  hex(last_word))
+            exit()
+
+        # indexes of stuff (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 = p32[6] + header_length16 + 1
+            i_stop_1 = i_wf_stop  # end of the wf record
+            i_start_2 = i_wf_start  # beginning of the wf record
+            i_stop_2 = i_start_1
+
+        # handle the waveform(s)
+        #energy_wf = np.zeros(ene_wf_length16)  # not used rn
+        tbwf = tb['waveform']['values'].nda[ii]
+        if wf_length32 > 0:
+            if not buffer_wrap:
+                if i_wf_stop - i_wf_start != expected_wf_length:
+                    print("ERROR: event %d, we expected %d WF samples and only got %d" %
+                          (ievt, expected_wf_length, i_wf_stope - i_wf_start))
+                tbwf[:expected_wf_length] = p16[i_wf_start:i_wf_stop]
+            else:
+                len1 = i_stop_1-i_start_1
+                len2 = i_stop_2-i_start_2
+                if len1+len2 != expected_wf_length:
+                    print("ERROR: event %d, we expected %d WF samples and only got %d" %
+                          (ievt, expected_wf_length, len1+len2))
+                    exit()
+                tbwf[:len1] = p16[i_start_1:i_stop_1]
+                tbwf[len1:len1+len2] = p16[i_start_2:i_stop_2]
+
+
+        # set the event number (searchable HDF5 column)
+        tb['ievt'].nda[ii] = self.ievt
+        self.ievt += 1
+        tb.push_row()
+
+
+class ORCAGretina4M(OrcaDecoder):
+    """
+    Decoder for Majorana Gretina4M digitizer data
+
+    NOTE: Tom Caldwell made some nice new summary slides on a 2019 LEGEND call
+    https://indico.legend-exp.org/event/117/contributions/683/attachments/467/717/mjd_data_format.pdf
+    """
+    def __init__(self, *args, **kwargs):
+
+        self.decoder_name = 'ORGretina4MWaveformDecoder'
+        self.orca_class_name = 'ORGretina4MModel'
+
+        self.decoded_values_template = {
+            'packet_id': {
+               'dtype': 'uint32',
+             },
+            'ievt': {
+              'dtype': 'uint32',
+            },
+            'energy': {
+              'dtype': 'uint32',
+              'units': 'adc',
+            },
+            'timestamp': {
+              'dtype': 'uint32',
+              'units': 'clock_ticks',
+            },
+            'crate': {
+              'dtype': 'uint8',
+            },
+            'card': {
+              'dtype': 'uint8',
+            },
+            'channel': {
+              'dtype': 'uint8',
+            },
+            "board_id": {
+              'dtype': 'uint32',
+            },
+            'waveform': {
+              'dtype': 'int16',
+              'datatype': 'waveform',
+              'length': 2016, # shorter if multispampling is used
+              'sample_period': 10,
+              'sample_period_units': 'ns',
+              'units': 'adc',
+            },
+        }
+
+        super().__init__(*args, **kwargs)
+        self.decoded_values = {}
+        self.ievt = 0
+        self.skipped_channels = {}
+        self.use_MS = False
+        self.wf_skip = 16 # the first ~dozen samples are sometimes junk
+        # channel pars for multisampling mode
+        self.ft_len = {}
+        self.ps = {}
+        self.div = {}
+        self.rises = np.zeros(2016)
+        self.remainders = np.zeros(2016)
+
+
+    def get_decoded_values(self, channel=None):
+        if channel is None:
+            dec_vals_list = self.decoded_values.items()
+            if len(dec_vals_list) == 0:
+                print("ORGretina4MModel: Error: decoded_values not built yet!")
+                return None
+            return list(dec_vals_list)[0][1] # Get first thing we find
+        if channel in self.decoded_values: return self.decoded_values[channel]
+        print("ORGretina4MModel: Error: No decoded values for channel", channel)
+        print(self.decoded_values.keys())
+        return None
+
+
+    def max_n_rows_per_packet(self):
+        return 1
+
+
+    def set_object_info(self, object_info):
+        self.object_info = object_info
+
+        # parse object_info for important info
+        for card_dict in self.object_info:
+            crate = card_dict['Crate']
+            card = card_dict['Card']
+
+            is_enabled = card_dict['Enabled']
+            ftcnt = card_dict['FtCnt']
+            presum_rates = [ 2, 4, 8, 10 ] # number presummed in MS
+            mrpsrt = card_dict['Mrpsrt'] # index for channel's presum rate
+            dividers = [1, 2, 4, 8 ] # dividers for presummed data
+            mrpsdv = card_dict['Mrpsdv'] # index for channel's divider
+            for channel in range(10):
+                # only care about enabled channels
+                if not is_enabled[channel]: continue
+                ccc = get_ccc(crate, card, channel)
+                self.decoded_values[ccc] = copy.deepcopy(self.decoded_values_template)
+                sd = self.decoded_values[ccc] # alias
+
+                # find MS parameters
+                # MS is on if FtCnt > 0
+                # forget pre-rising-edge MS: it's broken so MJ doesn't use it
+                # Skip samples at beginning, fully sample, then FtCnt samples of
+                # pre-sampled, divided by div. Make one long fully-sampled wf.
+                ft_len = ftcnt[channel]
+                self.ft_len[ccc] = ft_len
+                if self.is_multisampled(ccc):
+                    ps = presum_rates[mrpsrt[channel]]
+                    self.ps[ccc] = ps
+                    self.div[ccc] = dividers[mrpsdv[channel]]
+                    # chop off 3 values at the end because 2 are bad and we need
+                    # one for interpolation
+                    min_len = 2018 - ft_len - self.wf_skip + (ps-1)*(ft_len-3)
+                    sd['waveform']['length'] = min_len
+                    if min_len > len(self.remainders):
+                        self.remainders.resize(min_len)
+                else: sd['waveform']['length'] = 2016 - self.wf_skip
+
+
+    def is_multisampled(self, ccc):
+        if ccc in self.ft_len: return self.ft_len[ccc] > 0
+        else: print('channel', ccc, 'not in ft_len...')
+        return False
+
+
+    def decode_packet(self, packet, lh5_tables, packet_id, header_dict, verbose=False):
+        """
+        Parse the header for an individual event
+        """
+        pu16 = np.frombuffer(packet, dtype=np.uint16)
+        p16 = np.frombuffer(packet, dtype=np.int16)
+
+        crate = (pu16[1] >> 5) & 0xF
+        card = pu16[1] & 0x1F
+        channel = pu16[4] & 0xf
+        ccc = get_ccc(crate, card, channel)
+
+        # aliases for brevity
+        tb = lh5_tables
+        if not isinstance(tb, Table):
+            if ccc not in lh5_tables:
+                if ccc not in self.skipped_channels:
+                    self.skipped_channels[ccc] = 0
+                self.skipped_channels[ccc] += 1
+                return
+            tb = lh5_tables[ccc]
+        ii = tb.loc
+
+        tb['packet_id'].nda[ii] = packet_id
+        tb['ievt'].nda[ii] = self.ievt
+        tb['energy'].nda[ii] = pu16[9] + ((pu16[10] & 0x1FF) << 16)
+        tb['timestamp'].nda[ii] = pu16[6] + (pu16[7] << 16) + (pu16[8] << 32)
+        tb['crate'].nda[ii] = crate
+        tb['card'].nda[ii] = card
+        tb['channel'].nda[ii] = channel
+        tb['board_id'].nda[ii] = (pu16[4] & 0xFFF0) >> 4
+
+        # wf starts from p16[18] and is always 2018 samples
+        # we will chop off the start (~a dozen) and end (last 2), but not until
+        # after we have handled multisampling
+        wf = p16[18:]
+        wf_len = 2018
+        if self.is_multisampled(ccc):
+            # get ccc pars
+            ps = self.ps[ccc]
+            div = self.div[ccc]
+            ratio = div / ps
+
+            # find start of presummed section
+            # because it's not always self.ft_len :(
+            ift = wf_len - self.ft_len[ccc] - 2
+            min_diff = np.inf
+            min_diff_ift = ift
+            for i in range(self.wf_skip+1):
+                d1 = abs(wf[ift+i]*ratio - wf[ift+i-1])
+                if d1 < min_diff:
+                    min_diff = d1
+                    min_diff_ift = ift+i
+            ift = min_diff_ift
+            ift_out = ift - self.wf_skip
+
+            # copy over fully-sampled portion
+            tb['waveform']['values'].nda[ii][:ift_out] = wf[self.wf_skip:ift]
+
+            # compute slopes for interpolation
+            # rise[i-1] is the slope prior to sample i, while rise[i] is the
+            # slope following it
+            if len(self.rises) != len(wf): self.rises.resize(len(wf)-1)
+            self.rises[:] = wf[1:] - wf[:-1]
+            # correct the value right before ift
+            self.rises[ift-1] = wf[ift] - np.sum(wf[ift-ps:ift]) / div
+
+            # store double-precision values in self.remainders (later it will be
+            # used to compute remainders and do round-off
+            # Note: the interpolation done here is meant to preserve the
+            # presumming: re-presumming should reproduce the original waveform
+            rem_len = len(self.remainders) - ift_out
+            for ips in range(ps):
+                nn = int(np.floor( (rem_len - ips - 1) / ps ) + 1)
+                self.remainders[ift_out+ips::ps] = wf[ift:ift+nn]
+                self.remainders[ift_out+ips::ps] -= (2*ips+1)/(2*ps)*self.rises[ift-1:ift+nn-1]
+                self.remainders[ift_out+ips::ps] *= ratio
+
+            # now set the output based on rounding cumulative remainders
+            wf_out = tb['waveform']['values'].nda[ii]
+            if len(self.remainders) != len(wf_out):
+                print("Error: remainders len", len(self.remainders), "but wf_out len", len(wf_out))
+                return
+            for ips in range(ps):
+                np.rint(self.remainders[ift_out+ips::ps], out=wf_out[ift_out+ips::ps], casting='unsafe')
+                if ips == ps-1: break;
+                self.remainders[ift_out+ips::ps] -= wf_out[ift_out+ips::ps]
+                self.remainders[ift_out+ips+1::ps] += self.remainders[ift_out+ips:-1:ps]
+
+        else:
+            length = 2016 - self.wf_skip
+            start = 16+self.wf_skip
+            tb['waveform']['values'].nda[ii][:] = p16[start:start+length]
+
+        tb.push_row()
+
+        # update the event number (searchable HDF5 column)
+        self.ievt += 1
+
+
+class SIS3316ORCADecoder(OrcaDecoder):
+    """
+    Decode ORCA Struck 3316 digitizer data.
+    Thanks to J. Browning of COHERENT for getting this updated!
+
+    TODO:
+    handle per-channel data (gain, ...)
+    most metadata of Struck header (energy, ...)
+    """
+    def __init__(self, *args, **kwargs):
+
+        self.decoder_name = 'ORSIS3316WaveformDecoder'
+        self.orca_class_name = 'ORSIS3316Model'
+
+        # store an entry for every event
+        self.decoded_values_template = {
+            'packet_id': {
+               'dtype': 'uint32',
+             },
+            'ievt': {
+              'dtype': 'uint32',
+            },
+            'energy': {
+              'dtype': 'uint32',
+              'units': 'adc',
+            },
+            'energy_first': {
+              'dtype': 'uint32',
+            },
+            'timestamp': {
+              'dtype': 'uint64',
+              'units': 'clock_ticks',
+            },
+            'crate': {
+              'dtype': 'uint8',
+            },
+            'card': {
+              'dtype': 'uint8',
+            },
+            'channel': {
+              'dtype': 'uint8',
+            },
+            'waveform': {
+              'dtype': 'uint16',
+              'datatype': 'waveform',
+              'length': 65532, # max value. override this before initializing buffers to save RAM
+              'sample_period': 8, # override if a different clock rate is used
+              'sample_period_units': 'ns',
+              'units': 'adc',
+            },
+        }
+        super().__init__(*args, **kwargs) # also initializes the garbage df (whatever that means...)
+
+        # self.event_header_length = 1 #?
+        self.decoded_values = {}
+        self.skipped_channels = {}
+        self.ievt = 0
+
+
+    def get_decoded_values(self, channel=None):
+        if channel is None:
+            dec_vals_list = self.decoded_values.items()
+            if len(dec_vals_list) == 0:
+                print("ORSIS3302Model: Error: decoded_values not built yet!")
+                return None
+            return list(dec_vals_list)[0][1] # Get first thing we find
+        if channel in self.decoded_values:
+            return self.decoded_values[channel]
+        print("ORSIS3316Model: Error: No decoded values for channel", channel)
+        return None
+
+
+    def set_object_info(self, object_info):
+        self.object_info = object_info
+        # parse object_info for important info
+        for card_dict in self.object_info:
+            crate = card_dict['Crate']
+            card = card_dict['Card']
+            trace_length = card_dict['rawDataBufferLen']
+            import copy
+
+            for channel in range(16):
+                # get ccc and make an empty table for that ccc
+                # TODO: should try and only do this for enabled channels
+                ccc = get_ccc(crate, card, channel)
+                self.decoded_values[ccc] = copy.deepcopy(self.decoded_values_template)
+
+                if trace_length <= 0 or trace_length > 2**16:
+                    print('SIS3316ORCADecoder Error: invalid trace_length', trace_length)
+                    sys.exit()
+
+                self.decoded_values[ccc]['waveform']['length'] = trace_length
+
+
+
+    def max_n_rows_per_packet(self):
+        return 1
+
+
+    def decode_packet(self, packet, lh5_tables, packet_id, header_dict, verbose=False):
+
+        # parse the raw event data into numpy arrays of 16 and 32 bit ints
+        evt_data_32 = np.fromstring(packet, dtype=np.uint32)
+        evt_data_16 = np.fromstring(packet, dtype=np.uint16)
+
+        # tb = lh5_tables
+
+
+        # read the crate/card/channel first
+        crate = (evt_data_32[0] >> 21) & 0xF
+        card = (evt_data_32[0] >> 16) & 0x1F
+        channel = (evt_data_32[0] >> 8) & 0xFF
+        ccc = get_ccc(crate, card, channel)
+
+
+        if isinstance(list(lh5_tables.keys())[0], int):
+            if ccc not in lh5_tables:
+                print('ccc not in lh5_tables')
+                if ccc not in self.skipped_channels:
+                    self.skipped_channels[ccc] = 0
+                self.skipped_channels[ccc] += 1
+                return
+            tb = lh5_tables[ccc]
+        ii = tb.loc
+
+        tb['packet_id'].nda[ii] = packet_id
+
+        # TODO: Figure out the header, particularly card/crate/channel/timestamp
+        n_lost_msb = 0
+        n_lost_lsb = 0
+        n_lost_records = 0
+        tb['crate'].nda[ii] = evt_data_32[3]
+        tb['card'].nda[ii] = evt_data_32[4]
+        try:
+            tb['channel'].nda[ii] = (evt_data_32[9] & 0xFFF0) >> 4
+        except:
+            print('Something went wrong')
+            tb['channel'].nda[ii] = 33
+        buffer_wrap = 0
+        crate_card_chan = crate + card + channel
+        wf_length_32 = 0
+        ene_wf_length = evt_data_32[4]
+        evt_header_id = 0
+        try:
+            tb['timestamp'].nda[ii] = evt_data_32[10] + ((evt_data_32[9] & 0xffff0000) << 16)
+        except:
+            print('something went wrong here too')
+            tb['timestamp'].nda[ii] = 0
+
+        # compute expected and actual array dimensions
+        # wf_length16 = 65000 # OK this stuff is just random <--
+        orca_helper_length16 = 52
+        header_length16 = orca_helper_length16
+        ene_wf_length16 = 2 * ene_wf_length
+        footer_length16 = 0
+
+        #expected_wf_length = (len(evt_data_16) - header_length16 - ene_wf_length16)
+        expected_wf_length = (len(evt_data_16) - header_length16) #- ene_wf_length16)
+
+        # print('expected_wf_length... ', len(evt_data_16) - header_length16)
+
+        # if wf_length16 != expected_wf_length:
+        #     print(len(evt_data_16), orca_helper_length16, header_length16,
+        #          footer_length16)
+        #     print("ERROR: Waveform size %d doesn't match expected size %d." %
+        #       (wf_length16, expected_wf_length))
+        #     print("       The Last Word (should be 0xdeadbeef):",)
+        #     exit()
+
+        # indexes of stuff (all referring to the 16 bit array)
+        i_wf_start = header_length16
+        #i_wf_stop = i_wf_start + wf_length16
+        i_wf_stop = i_wf_start + expected_wf_length
+        i_ene_start = i_wf_stop + 1
+        i_ene_stop = i_ene_start + ene_wf_length16
+
+        tbwf = tb['waveform']['values'].nda[ii]
+        # handle the waveform(s)
+        # if wf_length16 > 0:
+        if expected_wf_length > 0:
+            # print('len(tb[waveform][values].nda[ii]) ', len(tb['waveform']['values'].nda[ii]))
+            # print(len(evt_data_16[i_wf_start:i_wf_stop]))
+            try:
+                tb['waveform']['values'].nda[ii] = evt_data_16[i_wf_start:i_wf_stop]
+            except:
+                print('hmm.. packet id: ', packet_id)
+                print('  got: ',len(evt_data_16[i_wf_start:i_wf_stop]))
+                #print(evt_data_32[i_wf_start:i_wf_stop])
+                print('  expected: ',len(tb['waveform']['values'].nda[ii]))
+
+        # TODO check if number of events matches expected
+        # if len(wf_data) != expected_wf_length:
+        #    print("ERROR: We expected %d WF samples and only got %d" %
+        #          (expected_wf_length, len(wf_data)))
+        #    exit()
+
+        # final raw wf array
+
+        # set the event number (searchable HDF5 column)\
+        tb['ievt'].nda[ii] = self.ievt
+        self.ievt += 1
+        tb.push_row()
diff --git a/attic/pygama/raw/orca_flashcam.py b/attic/pygama/raw/orca_flashcam.py
new file mode 100644
index 000000000..f4767007f
--- /dev/null
+++ b/attic/pygama/raw/orca_flashcam.py
@@ -0,0 +1,130 @@
+import copy
+import gc
+import logging
+from typing import Any
+
+import numpy as np
+
+from pygama.raw.fc.fc_event_decoder import fc_decoded_values
+from pygama.raw.orca.orca_base import OrcaDecoder
+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 ORFlashCamListenerStatusDecoder(OrcaDecoder):
+    """
+    Decoder for FlashCam status packets written by ORCA
+
+    Some of the card level status data contains an  array of values
+    (temperatures for instance) for each card.  Since lh5 currently only
+    supports a 1d vector of 1d vectors, this (card,value) data has to be
+    flattened before populating the lh5 table.
+    """
+
+    def __init__(self, *args, **kwargs):
+
+        self.decoder_name    = 'ORFlashCamListenerStatusDecoder'
+        self.orca_class_name = 'ORFlashCamListenerModel'
+        self.nOtherErrors    = np.uint32(5)
+        self.nEnvMonitors    = np.uint32(16)
+        self.nCardTemps      = np.uint32(5)
+        self.nCardVoltages   = np.uint32(6)
+        self.nADCTemps       = np.uint32(2)
+        self.nCTILinks       = np.uint32(4)
+        self.nCards          = np.uint32(1)
+
+        self.decoded_values = {
+            'readout_id':  { 'dtype': 'uint16', },
+            'listener_id': { 'dtype': 'uint16', },
+            'cards':       { 'dtype': 'int32',  },
+            'status':      { 'dtype': 'int32',  },
+            'statustime':  { 'dtype': 'float64', 'units': 's', },
+            'cputime':     { 'dtype': 'float64', 'units': 's', },
+            'startoffset': { 'dtype': 'float64', 'units': 's', },
+            'card_fcio_id':  {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_status': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_event_number': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_pps_count': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_tick_count': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_max_ticks': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_total_errors': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_env_errors': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_cti_errors': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_link_errors': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards, },
+            'card_other_errors': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards * self.nOtherErrors, },
+            'card_temp': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards * self.nCardTemps,
+                'units':        'mC', },
+            'card_voltage': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards * self.nCardVoltages,
+                'units':        'mV', },
+            'card_current': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards,
+                'units':        'mA', },
+            'card_humidity': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards,
+                'units':        'o/oo', },
+            'card_adc_temp': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards * self.nADCTemps,
+                'units':        'mC', },
+            'card_cti_link': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards * self.nCTILinks, },
+            'card_card_link_state': {
+                'dtype':        'uint32',
+                'datatype':     'array<1>{array<1>{real}}',
+                'length_guess':  self.nCards * self.nCards, },
+        }
+
+        # arrays to temporarily store card-level decoded data
+        self.cdata = {}
+        self.resize_card_data(ncards=self.nCards)
+
+        super().__init__(args, kwargs)
diff --git a/src/pygama/raw/orca/orca_pollers.py b/attic/pygama/raw/orca_pollers.py
similarity index 100%
rename from src/pygama/raw/orca/orca_pollers.py
rename to attic/pygama/raw/orca_pollers.py
diff --git a/docs/Makefile b/docs/Makefile
index 32db5166d..8e69f8bb9 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -1,25 +1,14 @@
+SHELL := /bin/bash
 SOURCEDIR = source
 BUILDDIR = build
 
-# docs for all project versions will be placed here
-BUILDDIRALL = $(BUILDDIR)/allver/html
-
-# list in source/.py_api_exclude files or directories that should be excluded
-# from the API auto-generated documentation. No regex allowed
-PY_SOURCE_EXCLUDE = $(shell cat source/.py_api_exclude)
-
 all: apidoc
 	sphinx-build -M html "$(SOURCEDIR)" "$(BUILDDIR)" -W --keep-going
 
-allver:
-	sphinx-multiversion "$(SOURCEDIR)" "$(BUILDDIRALL)"
-	cp source/_assets/redirect.html "$(BUILDDIRALL)/index.html"
-
-allver-info:
-	sphinx-multiversion --dump-metadata "$(SOURCEDIR)" "$(BUILDDIRALL)"
-
 apidoc: clean-apidoc
-	sphinx-apidoc --private --module-first --force --output-dir "$(SOURCEDIR)/generated" ../src/pygama $(PY_SOURCE_EXCLUDE)
+	sphinx-apidoc --private --module-first --force \
+      --output-dir "$(SOURCEDIR)/generated" \
+      ../src/pygama ../src/pygama/_version.py
 
 clean-apidoc:
 	rm -rf "$(SOURCEDIR)/generated"
diff --git a/docs/source/.py_api_exclude b/docs/source/.py_api_exclude
deleted file mode 100644
index 31a2338a7..000000000
--- a/docs/source/.py_api_exclude
+++ /dev/null
@@ -1,1 +0,0 @@
-../src/pygama/_version.py
diff --git a/docs/source/_assets/redirect.html b/docs/source/_assets/redirect.html
deleted file mode 100644
index 42c049822..000000000
--- a/docs/source/_assets/redirect.html
+++ /dev/null
@@ -1,9 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>Redirecting to main branch</title>
-    <meta charset="utf-8">
-    <meta http-equiv="refresh" content="0; url=./main/index.html">
-    <link rel="canonical" href="https://legend-exp.github.io/pygama/main/index.html">
-  </head>
-</html>
diff --git a/docs/source/_templates/layout.html b/docs/source/_templates/layout.html
deleted file mode 100644
index 9bd28131d..000000000
--- a/docs/source/_templates/layout.html
+++ /dev/null
@@ -1,32 +0,0 @@
-{% extends "!layout.html" %}
-
-  {% block menu %}
-    {{ super() }}
-    {%- if current_version %}
-    <div class="rst-versions" data-toggle="rst-versions" role="note" aria-label="versions">
-      <span class="rst-current-version" data-toggle="rst-current-version">
-        <span class="fa fa-book"> Other Versions</span>
-        v: {{ current_version.name }}
-        <span class="fa fa-caret-down"></span>
-      </span>
-      <div class="rst-other-versions">
-        {%- if versions.tags %}
-        <dl>
-          <dt>Tags</dt>
-          {%- for item in versions.tags %}
-          <dd><a href="{{ item.url }}">{{ item.name }}</a></dd>
-          {%- endfor %}
-        </dl>
-        {%- endif %}
-        {%- if versions.branches %}
-        <dl>
-          <dt>Branches</dt>
-          {%- for item in versions.branches %}
-          <dd><a href="{{ item.url }}">{{ item.name }}</a></dd>
-          {%- endfor %}
-        </dl>
-        {%- endif %}
-      </div>
-    </div>
-    {%- endif %}
-  {% endblock %}
diff --git a/docs/source/conf.py b/docs/source/conf.py
index c115832a9..6b300dee4 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -3,107 +3,91 @@
 import sys
 from pathlib import Path
 
-sys.path.insert(0, Path('../../src').resolve().as_posix())
+from pkg_resources import get_distribution
 
-project = 'pygama'
-copyright = '2022, the LEGEND Collaboration'
+sys.path.insert(0, Path("../../src").resolve().as_posix())
+
+project = "pygama"
+copyright = "2020, the LEGEND Collaboration"
+version = get_distribution("pygama").version
 
 extensions = [
-    'sphinx.ext.githubpages',
-    'sphinx.ext.autodoc',
-    'sphinx.ext.mathjax',
-    'sphinx.ext.napoleon',
-    'sphinx.ext.intersphinx',
-    'sphinx_rtd_theme',
-    'sphinx_multiversion',
-    'sphinx_copybutton',
-    'myst_parser'
+    "sphinx.ext.githubpages",
+    "sphinx.ext.autodoc",
+    "sphinx.ext.mathjax",
+    "sphinx.ext.napoleon",
+    "sphinx.ext.intersphinx",
+    "sphinx_copybutton",
+    "myst_parser",
 ]
 
 source_suffix = {
-    '.rst': 'restructuredtext',
-    '.md': 'markdown',
+    ".rst": "restructuredtext",
+    ".md": "markdown",
 }
-master_doc = 'index'
-language = 'python'
-# in _templates/ we have a custom layout.html to include the version menu
-# (adapted from sphinx-multiversion docs)
-templates_path = ['_templates']
-pygments_style = 'sphinx'
+master_doc = "index"
+language = "python"
 
-# readthedocs.io Sphinx theme
-html_theme = 'sphinx_rtd_theme'
+# Furo theme
+html_theme = "furo"
+html_theme_options = {
+    "source_repository": "https://github.com/legend-exp/pygama",
+    "source_branch": "main",
+    "source_directory": "docs/source",
+}
+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',
+    "pygama._version",
+    "pandas",
     # 'numpy',
-    'matplotlib',
-    'mplhep',
-    'scipy',
-    'numba',
-    'pytest',
-    'pyhf',
-    'awkward',
-    'iminuit',
-    'boost-histogram',
-    'hepunits',
-    'hepstats',
-    'uproot',
-    'h5py',
-    'pint',
-    'pyfftw',
-    'tqdm',
-    'tinydb',
-    'parse'
+    "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
 # enforce consistent usage of NumPy-style docstrings
 napoleon_numpy_docstring = True
 napoleon_google_docstring = False
-napoleon_use_rtype = False
+napoleon_use_ivar = True
+napoleon_custom_sections = ["JSON Configuration Example"]
+
 
 # intersphinx
 intersphinx_mapping = {
-    'python': ('https://docs.python.org/3', None),
-    'numpy': ('https://numpy.org/doc/stable', None),
-    'numba': ('https://numba.readthedocs.io/en/stable', None),
-    'scipy': ('https://docs.scipy.org/doc/scipy', None),
-    'pandas': ('https://pandas.pydata.org/docs', None),
-    'matplotlib': ('https://matplotlib.org/stable', None),
-    'iminuit': ('https://iminuit.readthedocs.io/en/stable', None),
-    'h5py': ('https://docs.h5py.org/en/stable', None),
-    'pint': ('https://pint.readthedocs.io/en/stable', None)
+    "python": ("https://docs.python.org/3", None),
+    "numpy": ("https://numpy.org/doc/stable", None),
+    "numba": ("https://numba.readthedocs.io/en/stable", None),
+    "scipy": ("https://docs.scipy.org/doc/scipy", None),
+    "pandas": ("https://pandas.pydata.org/docs", None),
+    "matplotlib": ("https://matplotlib.org/stable", None),
+    "iminuit": ("https://iminuit.readthedocs.io/en/stable", None),
+    "h5py": ("https://docs.h5py.org/en/stable", None),
+    "pint": ("https://pint.readthedocs.io/en/stable", None),
 }
 
 # sphinx-autodoc
 # Include __init__() docstring in class docstring
-autoclass_content = 'both'
-autodoc_typehints = 'both'
-autodoc_typehints_description_target = 'documented_params'
-autodoc_typehints_format = 'short'
-
-# sphinx-multiversion
-
-# For now, we include only (certain) branches when building docs.
-# To add a specific release to the list of versions for which docs should be build,
-# one must create a new branch named `releases/...`
-smv_branch_whitelist = r'^(main|refactor|releases/.*)$'
-smv_tag_whitelist = '^$'
-smv_released_pattern = '^$'
-smv_outputdir_format = '{ref.name}'
-smv_prefer_remote_refs = False
-
-# HACK: we need to regenerate the API documentation before the actual build,
-# but it's not possible with the current sphinx-multiversion. Changes have been
-# proposed in this PR: https://github.com/Holzhaus/sphinx-multiversion/pull/62
-# but there's no timeline for merging yet. For the following option to be considered,
-# one needs to install the sphinx-multiversion-pre-post-build fork from PyPI
-smv_prebuild_command = 'make -ik apidoc'
-
-# The right way to find all docs versions is to look for matching branches on
-# the default remote
-smv_remote_whitelist = r'^origin$'
+autoclass_content = "both"
+autodoc_typehints = "both"
+autodoc_typehints_description_target = "documented_params"
+autodoc_typehints_format = "short"
diff --git a/docs/source/developer.rst b/docs/source/developer.rst
index 6beec187d..70985f32d 100644
--- a/docs/source/developer.rst
+++ b/docs/source/developer.rst
@@ -5,14 +5,14 @@ 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 *pygama*, clone the remote repository:
+To start developing :mod:`pygama`, clone the remote repository:
 
 .. code-block:: console
 
   $ git clone https://github.com/legend-exp/pygama
 
-All extra tools needed to develop *pygama* are listed as optional dependencies
-and can be installed via pip by running:
+All extra tools needed to develop :mod:`pygama` are listed as optional
+dependencies and can be installed via pip by running:
 
 .. code-block:: console
 
@@ -22,10 +22,41 @@ and can be installed via pip by running:
 Code style
 ----------
 
+* All functions and methods (arguments and return types) must be
+  `type-annotated <https://docs.python.org/3/library/typing.html>`_. Type
+  annotations for variables like class attributes are also highly appreciated.
+  Do not forget to
+
+  .. code-block:: python
+
+    from __future__ import annotations
+
+  at the top of a module implementation.
+* Messaging to the user is managed through the :mod:`logging` module. Do not
+  add :func:`print` statements. To make a logging object available in a module,
+  add this:
+
+  .. code-block:: python
+
+    import logging
+    log = logging.getLogger(__name__)
+
+  at the top. In general, try to keep the number of :func:`logging.debug` calls
+  low and use informative messages. :func:`logging.info` calls should be
+  reserved for messages from high-level routines (like
+  :func:`pygama.dsp.build_dsp`). Good code is never too verbose.
+* If an error condition leading to undefined behavior occurs, raise an
+  exception. try to find the most suitable between the `built-in exceptions
+  <https://docs.python.org/3/library/exceptions.html>`_, otherwise ``raise
+  RuntimeError("message")``. Do not raise ``Warning``\ s, use
+  :func:`logging.warning` for that and don't abort the execution.
+* Warning messages (emitted when a problem is encountered that does not lead to
+  undefined behavior) must be emitted through :func:`logging.warning` calls.
+
 A set of `pre-commit <https://pre-commit.com>`_ hooks is configured to make
-sure that *pygama* coherently follows standard coding style conventions. The
-pre-commit tool is able to identify common style problems and automatically fix
-them, wherever possible. Configured hooks are listed in the
+sure that :mod:`pygama` coherently follows standard coding style conventions.
+The pre-commit tool is able to identify common style problems and automatically
+fix them, wherever possible. Configured hooks are listed in the
 ``.pre-commit-config.yaml`` file at the project root folder. They are run
 remotely on the GitHub repository through the `pre-commit bot
 <https://pre-commit.ci>`_, but can also be run locally before submitting a
@@ -36,7 +67,7 @@ pull request (recommended):
   $ cd pygama
   $ pip install '.[test]'
   $ pre-commit run --all-files  # analyse the source code and fix it wherever possible
-  $ pre-commit install          # install a Git pre-commit hook (optional)
+  $ pre-commit install          # install a Git pre-commit hook (optional but recommended)
 
 For a more comprehensive guide, check out the `Scikit-HEP documentation about
 code style <https://scikit-hep.org/developer/style>`_.
@@ -44,20 +75,20 @@ code style <https://scikit-hep.org/developer/style>`_.
 Testing
 -------
 
-* The *pygama* test suite is available below ``tests/``. We use `pytest
+* The :mod:`pygama` test suite is available below ``tests/``. We use `pytest
   <https://docs.pytest.org>`_ to run tests and analyze their output. As
   a starting point to learn how to write good tests, reading of `the
   Scikit-HEP Intro to testing <https://scikit-hep.org/developer/pytest>`_ is
   recommended. Refer to `pytest's how-to guides
   <https://docs.pytest.org/en/stable/how-to/index.html>`_ for a complete
   overview.
-* *pygama* tests belong to three categories:
+* :mod:`pygama` tests belong to three categories:
 
   :unit tests: Should ensure the correct behaviour of each function
-      independently, possibly without relying on other *pygama* methods. The
-      existence of these micro-tests makes it possible to promptly identify and
-      fix the source of a bug. An example of this are tests for each single DSP
-      processor
+      independently, possibly without relying on other :mod:`pygama` methods.
+      The existence of these micro-tests makes it possible to promptly identify
+      and fix the source of a bug. An example of this are tests for each single
+      DSP processor
 
   :integration tests: Should ensure that independent parts of the code base
       work well together and are integrated in a cohesive framework. An example
@@ -93,12 +124,13 @@ Testing
 Documentation
 -------------
 
-We adopt best practices in writing and maintaining *pygama*'s documentation. When
-contributing to the project, make sure to implement the following:
+We adopt best practices in writing and maintaining :mod:`pygama`'s
+documentation. When contributing to the project, make sure to implement the
+following:
 
 * Documentation should be exclusively available on the Project website
-  https://legend-exp.github.io/pygama. No READMEs, GitHub/LEGEND wiki pages
-  should be written.
+  `pygama.readthedocs.io <https://pygama.readthedocs.io>`_. No READMEs,
+  GitHub/LEGEND wiki pages should be written.
 * Pull request authors are required to provide sufficient documentation for
   every proposed change or addition.
 * Documentation for functions, classes, modules and packages should be provided
@@ -149,7 +181,7 @@ required. You can get all the needed dependencies by running:
   $ cd pygama
   $ pip install '.[docs]'
 
-To build documentation for the current Git ref, run the following commands:
+To build documentation, run the following commands:
 
 .. code-block:: console
 
@@ -158,18 +190,8 @@ To build documentation for the current Git ref, run the following commands:
   $ make
 
 Documentation can be then displayed by opening ``build/html/index.html`` with a
-web browser.  To build documentation for all main *pygama* versions (development
-branch and stable releases), run
-
-.. code-block:: console
-
-  $ git fetch --prune origin
-  $ cd docs
-  $ make clean
-  $ make allver
-
-and display the documentation by opening ``build/allver/html/index.html``. This
-documentation is also deployed to the *pygama* website.
+web browser. Documentation for the :mod:`pygama` website is built and deployed by
+`Read the Docs <https://readthedocs.org/projects/pygama>`_.
 
 Versioning
 ----------
diff --git a/docs/source/manuals/build_dsp.rst b/docs/source/manuals/build_dsp.rst
index 7ba6b8b46..8830594b7 100644
--- a/docs/source/manuals/build_dsp.rst
+++ b/docs/source/manuals/build_dsp.rst
@@ -39,6 +39,8 @@ Writing custom processors
 
     # 1) Import Python modules
     #
+    from __future__ import annotations
+
     import numpy as np
     from numba import guvectorize
 
@@ -67,14 +69,46 @@ Writing custom processors
     # - Use "w_" for waveforms, "t_" for indexes, "a_" for amplitudes
     # - Use underscore casing for the name of the processor and variables, e.g.,
     #   "a_trap_energy_in" or "t_trigger_in"
+    # - use type annotations
     #
-    def the_processor_template(w_in, t_in, a_in, w_out, t_out):
+    def the_processor_template(w_in: np.ndarray,
+                               t_in: float,
+                               a_in: float,
+                               w_out: np.ndarray,
+                               t_out: float) -> None:
 
         # 4) Document the algorithm
         #
-        """
-        Add here a complete description of what the processor does, including the
-        meaning of input and output variables.
+        """One-liner description of the processor.
+
+        Add here a more detailed description of what the processor does.
+        Document input parameters in the "Parameters" section. Add a JSON
+        example for ProcessingChain configuration in the last section.
+
+        Parameters
+        ----------
+        w_in
+            the input waveform.
+        t_in
+            a scalar parameter in the time domain
+        a_in
+            a scalar parameter in the amplitude domain
+        w_out
+            the output waveform
+        t_out
+            an output scalar value in the time domain
+
+        JSON Configuration Example
+        --------------------------
+
+        .. code-block :: json
+
+            "wf_bl": {
+                "function": "the_processor_template",
+                "module": "pygama.dsp.processors",
+                "args": ["waveform", "t_a", "a_b", "wf_filtered", "t_result"],
+                "unit": "ADC"
+            }
         """
 
         # 5) Initialize output parameters
diff --git a/setup.cfg b/setup.cfg
index 569e62ac7..e985cbc41 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,15 +20,13 @@ classifiers =
     Programming Language :: Python
     Programming Language :: Python :: 3
     Programming Language :: Python :: 3 :: Only
-    Programming Language :: Python :: 3.9
-    Programming Language :: Python :: 3.10
     Topic :: Scientific/Engineering
     Topic :: Scientific/Engineering :: Information Analysis
     Topic :: Scientific/Engineering :: Mathematics
     Topic :: Scientific/Engineering :: Physics
     Topic :: Software Development
 project_urls =
-    Documentation = https://legend-exp.github.io/pygama
+    Documentation = https://pygama.readthedocs.io
 
 [options]
 packages = find:
@@ -37,7 +35,7 @@ install_requires =
     iminuit
     matplotlib
     numba!=0.53.*,!=0.54.*
-    numpy
+    numpy>=1.21
     pandas
     parse
     pint
@@ -63,13 +61,12 @@ all =
 daq =
     fcutils@git+https://github.com/legend-exp/pyfcutils@v0.1.0#egg=fcutils
 docs =
+    furo
     jupyter
     myst-parser
     pygama[daq]
     sphinx
     sphinx-copybutton
-    sphinx-multiversion-pre-post-build>=0.2.4
-    sphinx-rtd-theme
 test =
     legend-testdata@git+https://github.com/legend-exp/legend-testdata-py@v0.2.0#egg=legend-testdata
     pre-commit
@@ -79,3 +76,6 @@ test =
 
 [options.package_data]
 * = *.json
+
+[flake8]
+extend-ignore = E203, E501
diff --git a/setup.py b/setup.py
index b1eaac121..f6844919c 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,4 @@
-import setuptools_scm
+import setuptools_scm  # noqa: F401
 from setuptools import setup
 
 setup()
diff --git a/src/pygama/__init__.py b/src/pygama/__init__.py
index 703bf5ac1..9d9d7f987 100644
--- a/src/pygama/__init__.py
+++ b/src/pygama/__init__.py
@@ -1,10 +1,8 @@
 """
 Pygama: decoding and processing digitizer data.
-Check out the `online documentation <https://legend-exp.github.io/pygama>`_
+Check out the `online documentation <https://pygama.readthedocs.io>`_
 """
 
 from pygama._version import version as __version__
 
-__all__ = [
-    "__version__"
-]
+__all__ = ["__version__"]
diff --git a/src/pygama/cli.py b/src/pygama/cli.py
index 5d209b345..348c305bc 100644
--- a/src/pygama/cli.py
+++ b/src/pygama/cli.py
@@ -29,14 +29,19 @@ def pygama_cli():
     """
 
     parser = argparse.ArgumentParser(
-        prog='pygama',
-        description="pygama's command-line interface")
+        prog="pygama", description="pygama's command-line interface"
+    )
 
     # global options
-    parser.add_argument('--version', action='store_true',
-                        help="""Print pygama version and exit""")
-    parser.add_argument('--verbose', '-v', action='store_true',
-                        help="""Increase the program verbosity""")
+    parser.add_argument(
+        "--version", action="store_true", help="""Print pygama version and exit"""
+    )
+    parser.add_argument(
+        "--verbose",
+        "-v",
+        action="store_true",
+        help="""Increase the program verbosity""",
+    )
 
     subparsers = parser.add_subparsers()
 
@@ -50,17 +55,19 @@ 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)
+        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)
     else:
-        logging.basicConfig(level=logging.INFO,
-                            format="%(name)s [%(levelname)s] %(message)s")
+        logging.basicConfig(
+            level=logging.INFO, format="%(name)s [%(levelname)s] %(message)s"
+        )
 
     if args.version:
-        print(pygama.__version__)
+        print(pygama.__version__)  # noqa: T201
         sys.exit()
 
     args.func(args)
@@ -70,25 +77,42 @@ def add_build_raw_parser(subparsers):
     """Configure :func:`.raw.build_raw.build_raw` command line interface"""
 
     parser_d2r = subparsers.add_parser(
-        'build-raw', description="""Convert data into LEGEND HDF5 (LH5) raw format""")
-    parser_d2r.add_argument('in_stream', nargs='+',
-                            help="""Input stream. Can be a single file, a list
+        "build-raw", description="""Convert data into LEGEND HDF5 (LH5) raw format"""
+    )
+    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""")
-    parser_d2r.add_argument('--stream-type', '-t',
-                            help="""Input stream type name. Use this if 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""")
-    parser_d2r.add_argument('--out-spec', '-o',
-                            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""")
-    parser_d2r.add_argument('--max-rows', '-n', type=int, default=np.inf,
-                            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""")
+                            pygama""",
+    )
+    parser_d2r.add_argument(
+        "--out-spec",
+        "-o",
+        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"""
+    )
+    parser_d2r.add_argument(
+        "--max-rows",
+        "-n",
+        type=int,
+        default=np.inf,
+        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"""
+    )
 
     parser_d2r.set_defaults(func=build_raw_cli)
 
@@ -98,60 +122,125 @@ def build_raw_cli(args):
 
     for stream in args.in_stream:
         basename = os.path.splitext(os.path.basename(stream))[0]
-        build_raw(stream, in_stream_type=args.stream_type,
-                  out_spec=args.out_spec, buffer_size=args.buffer_size,
-                  n_max=args.max_rows, overwrite=args.overwrite,
-                  orig_basename=basename)
+        build_raw(
+            stream,
+            in_stream_type=args.stream_type,
+            out_spec=args.out_spec,
+            buffer_size=args.buffer_size,
+            n_max=args.max_rows,
+            overwrite=args.overwrite,
+            orig_basename=basename,
+        )
 
 
 def add_build_dsp_parser(subparsers):
     """Configure :func:`.dsp.build_dsp.build_dsp` command line interface"""
 
     parser_r2d = subparsers.add_parser(
-        'build-dsp', description="""Process LH5 raw files and produce a
-        dsp file using a JSON configuration""")
-    parser_r2d.add_argument('raw_lh5_file', nargs='+',
-                            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""")
-    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
+        "build-dsp",
+        description="""Process LH5 raw files and produce a
+        dsp file using a JSON configuration""",
+    )
+    parser_r2d.add_argument(
+        "raw_lh5_file",
+        nargs="+",
+        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""",
+    )
+    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>_dsp.lh5""")
-    parser_r2d.add_argument('--database', '-d', default=None,
-                            help="""JSON file to read database parameters from.
+                            <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""")
-    parser_r2d.add_argument('--output-pars', '-p', nargs='*', default=None,
-                            help="""List of additional output DSP parameters
+                            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""")
-    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('--block', '-b', default=16, type=int,
-                            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""")
+                            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""",
+    )
+    parser_r2d.add_argument(
+        "--block",
+        "-b",
+        default=16,
+        type=int,
+        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""",
+    )
 
     group = parser_r2d.add_mutually_exclusive_group()
-    group.add_argument('--overwrite', '-w', action='store_const',
-                       const='r', dest='writemode', default='r',
-                       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. Useful with the --outpar option""")
-    group.add_argument('--append', '-a', action='store_const', const='a',
-                       dest='writemode', help="""Append values to existing
-                       file""")
+    group.add_argument(
+        "--overwrite",
+        "-w",
+        action="store_const",
+        const="r",
+        dest="writemode",
+        default="r",
+        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. Useful with the --outpar option""",
+    )
+    group.add_argument(
+        "--append",
+        "-a",
+        action="store_const",
+        const="a",
+        dest="writemode",
+        help="""Append values to existing
+                       file""",
+    )
 
     parser_r2d.set_defaults(func=build_dsp_cli)
 
@@ -160,25 +249,32 @@ def build_dsp_cli(args):
     """Passes command line arguments to :func:`.dsp.build_dsp.build_dsp`."""
 
     if len(args.raw_lh5_file) > 1 and args.output is not None:
-        raise NotImplementedError('not possible to set multiple output file names yet')
+        raise NotImplementedError("not possible to set multiple output file names yet")
 
     out_files = []
     if len(args.raw_lh5_file) == 1:
         if args.output is None:
             basename = os.path.splitext(os.path.basename(args.raw_lh5_file[0]))[0]
-            basename = basename.removesuffix('_raw')
-            out_files.append(f'{basename}_dsp.lh5')
+            basename = basename.removesuffix("_raw")
+            out_files.append(f"{basename}_dsp.lh5")
         else:
             out_files.append(args.output)
     else:
         for file in args.raw_lh5_file:
             basename = os.path.splitext(os.path.basename(file))[0]
-            basename = basename.removesuffix('_raw')
-            out_files.append(f'{basename}_dsp.lh5')
+            basename = basename.removesuffix("_raw")
+            out_files.append(f"{basename}_dsp.lh5")
 
     for i in range(len(args.raw_lh5_file)):
-        build_dsp(args.raw_lh5_file[i], out_files[i], args.config,
-                  lh5_tables=args.hdf5_groups, database=args.database,
-                  outputs=args.output_pars, n_max=args.max_rows,
-                  write_mode=args.writemode, buffer_len=args.chunk,
-                  block_width=args.block)
+        build_dsp(
+            args.raw_lh5_file[i],
+            out_files[i],
+            args.config,
+            lh5_tables=args.hdf5_groups,
+            database=args.database,
+            outputs=args.output_pars,
+            n_max=args.max_rows,
+            write_mode=args.writemode,
+            buffer_len=args.chunk,
+            block_width=args.block,
+        )
diff --git a/src/pygama/dsp/__init__.py b/src/pygama/dsp/__init__.py
index 8988108e8..d23f19a6d 100644
--- a/src/pygama/dsp/__init__.py
+++ b/src/pygama/dsp/__init__.py
@@ -1,14 +1,13 @@
-"""
+r"""
 The pygama signal processing framework is contained in the :mod:`.dsp`
 submodule. This code is responsible for running a variety of discrete signal
 processors on data, producing tier 2 (dsp) files from tier 1 (raw) files. The
 main contents of this submodule are:
 
 * :mod:`.processors`: A collection of Numba functions that perform individual
-  DSP transforms and reductions on our data. Individual processors are held in
-  the ``_processors`` directory, which is where contributors will write new
-  processors for inclusion in the analysis. Available processors include all
-  numpy ufuncs as well.
+  DSP transforms and reductions on our data. Here is where contributors will
+  write new processors for inclusion in the analysis. Available processors
+  include all :class:`numpy.ufunc`\ s as well.
 * :class:`.ProcessingChain`: A class that manages and efficiently runs a list
   of DSP processors
 * :func:`.build_processing_chain`: A function that builds a :class:`.ProcessingChain`
@@ -18,5 +17,7 @@
   file and writes into an output file, using the LH5 file format
 """
 
-from .build_dsp import build_dsp
-from .processing_chain import ProcessingChain, build_processing_chain
+from pygama.dsp.build_dsp import build_dsp
+from pygama.dsp.processing_chain import ProcessingChain, build_processing_chain
+
+__all__ = ["build_dsp", "ProcessingChain", "build_processing_chain"]
diff --git a/src/pygama/dsp/_processors/__init__.py b/src/pygama/dsp/_processors/__init__.py
deleted file mode 100644
index 37e898bfb..000000000
--- a/src/pygama/dsp/_processors/__init__.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""
-Package documentation
-"""
diff --git a/src/pygama/dsp/_processors/bl_subtract.py b/src/pygama/dsp/_processors/bl_subtract.py
deleted file mode 100644
index 08e982b50..000000000
--- a/src/pygama/dsp/_processors/bl_subtract.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32[:])",
-              "void(float64[:], float64, float64[:])"],
-             "(n),()->(n)", nopython=True, cache=True)
-def bl_subtract(w_in, a_baseline, w_out):
-    """
-    Subtract the constant baseline from the entire waveform.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    a_baseline : float
-        The baseline value to subtract
-    w_out : array-like
-        The output waveform with the baseline subtracted
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_bl": {
-            "function": "bl_subtract",
-            "module": "pygama.dsp.processors",
-            "args": ["waveform", "baseline", "wf_bl"],
-            "unit": "ADC",
-            "prereqs": ["waveform", "baseline"]
-        }
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(a_baseline):
-        return
-
-    w_out[:] = w_in[:] - a_baseline
diff --git a/src/pygama/dsp/_processors/convolutions.py b/src/pygama/dsp/_processors/convolutions.py
deleted file mode 100644
index a60b31612..000000000
--- a/src/pygama/dsp/_processors/convolutions.py
+++ /dev/null
@@ -1,243 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-def cusp_filter(length, sigma, flat, decay):
-    """
-    Apply a CUSP filter to the waveform.  Note that it is composed of a
-    factory function that is called using the init_args argument and that
-    the function the waveforms are passed to using args.
-
-    Parameters
-    ----------
-    length : int
-        The length of the filter to be convolved
-    sigma : float
-        The curvature of the rising and falling part of the kernel
-    flat : int
-        The length of the flat section
-    decay : int
-        The decay constant of the exponential to be convolved
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_cusp": {
-            "function": "cusp_filter",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_bl", "wf_cusp(101,f)"],
-            "unit": "ADC",
-            "prereqs": ["wf_bl"],
-            "init_args": ["len(wf_bl)-100", "40*us", "3*us", "45*us"]
-        }
-    """
-    if length <= 0:
-        raise DSPFatal('The length of the filter must be positive')
-
-    if sigma < 0:
-        raise DSPFatal('The curvature parameter must be positive')
-
-    if flat < 0:
-        raise DSPFatal('The length of the flat section must be positive')
-
-    if decay < 0:
-        raise DSPFatal('The decay constant must be positive')
-
-    lt       = int((length - flat) / 2)
-    flat_int = int(flat)
-    cusp     = np.zeros(length)
-    for ind in range(0, lt, 1):
-        cusp[ind] = float(np.sinh(ind / sigma) / np.sinh(lt / sigma))
-    for ind in range(lt, lt + flat_int + 1, 1):
-        cusp[ind] = 1
-    for ind in range(lt + flat_int + 1, length, 1):
-        cusp[ind] = float(np.sinh((length - ind) / sigma) / np.sinh(lt / sigma))
-
-    den   = [1, -np.exp(-1 / decay)]
-    cuspd = np.convolve(cusp, den, 'same')
-
-    @guvectorize(["void(float32[:], float32[:])",
-                  "void(float64[:], float64[:])"],
-                 "(n),(m)", forceobj=True)
-    def cusp_out(w_in, w_out):
-        """
-        Parameters
-        ----------
-        w_in : array-like
-            The input waveform
-        w_out : array-like
-            The filtered waveform
-        """
-        w_out[:] = np.nan
-
-        if np.isnan(w_in).any():
-            return
-
-        if len(cuspd) > len(w_in):
-            raise DSPFatal('The filter is longer than the input waveform')
-
-        w_out[:] = np.convolve(w_in, cuspd, 'valid')
-
-    return cusp_out
-
-def zac_filter(length, sigma, flat, decay):
-    """
-    Apply a ZAC (Zero Area CUSP) filter to the waveform.  Note that it is
-    composed of a factory function that is called using the init_args
-    argument and that the function the waveforms are passed to using args.
-
-    Parameters
-    ----------
-    length : int
-        The length of the filter to be convolved
-    sigma : float
-        The curvature of the rising and falling part of the kernel
-    flat : int
-        The length of the flat section
-    decay : int
-        The decay constant of the exponential to be convolved
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_zac": {
-            "function": "zac_filter",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_bl", "wf_zac(101,f)"],
-            "unit": "ADC",
-            "prereqs": ["wf_bl"],
-            "init_args": ["len(wf_bl)-100", "40*us", "3*us", "45*us"],
-        }
-    """
-    if length <= 0:
-        raise DSPFatal('The length of the filter must be positive')
-
-    if sigma < 0:
-        raise DSPFatal('The curvature parameter must be positive')
-
-    if flat < 0:
-        raise DSPFatal('The length of the flat section must be positive')
-
-    if decay < 0:
-        raise DSPFatal('The decay constant must be positive')
-
-    lt       = int((length - flat) / 2)
-    flat_int = int(flat)
-
-    # calculate cusp filter and negative parables
-    cusp = np.zeros(length)
-    par  = np.zeros(length)
-    for ind in range(0, lt, 1):
-        cusp[ind] = float(np.sinh(ind / sigma) / np.sinh(lt / sigma))
-        par [ind] = np.power(ind - lt / 2, 2) - np.power(lt / 2, 2)
-    for ind in range(lt, lt + flat_int + 1, 1):
-        cusp[ind] = 1
-    for ind in range(lt + flat_int + 1, length, 1):
-        cusp[ind] = float(np.sinh((length - ind) / sigma) / np.sinh(lt / sigma))
-        par [ind] = np.power(length - ind - lt / 2, 2) - np.power(lt / 2, 2)
-
-    # calculate area of cusp and parables
-    areapar, areacusp = 0, 0
-    for i in range(0, length, 1):
-        areapar  += par [i]
-        areacusp += cusp[i]
-
-    # normalize parables area
-    par = -par / areapar * areacusp
-
-    # create zac filter
-    zac = cusp + par
-
-    # deconvolve zac filter
-    den  = [1, -np.exp(-1 / decay)]
-    zacd = np.convolve(zac, den, 'same')
-
-    @guvectorize(["void(float32[:], float32[:])",
-                  "void(float64[:], float64[:])"],
-                 "(n),(m)", forceobj=True)
-    def zac_out(w_in, w_out):
-        """
-        Parameters
-        ----------
-        w_in : array-like
-            The input waveform
-        w_out : array-like
-            The filtered waveform
-        """
-        w_out[:] = np.nan
-
-        if np.isnan(w_in).any():
-            return
-
-        if len(zacd) > len(w_in):
-            raise DSPFatal('The filter is longer than the input waveform')
-
-        w_out[:] = np.convolve(w_in, zacd, 'valid')
-
-    return zac_out
-
-def t0_filter(rise, fall):
-
-    """
-    Apply a modified, asymmetric trapezoidal filter to the waveform.  Note
-    that it is composed of a factory function that is called using the init_args
-    argument and that the function the waveforms are passed to using args.
-
-    Parameters
-    ----------
-    rise : int
-        The length of the rise section.  This is the linearly increasing
-        section of the filter that performs a weighted average.
-    fall : int
-        The length of the fall section.  This is the simple averaging part
-        of the filter.
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_t0_filter": {
-            "function": "t0_filter",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_pz", "wf_t0_filter(3748,f)"],
-            "unit": "ADC",
-            "prereqs": ["wf_pz"],
-            "init_args": ["128*ns", "2*us"]
-        }
-    """
-    if rise < 0:
-        raise DSPFatal('The length of the rise section must be positive')
-
-    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)))
-
-    @guvectorize(["void(float32[:], float32[:])",
-                  "void(float64[:], float64[:])"],
-                 "(n),(m)", forceobj=True)
-    def t0_filter_out(w_in, w_out):
-        """
-        Parameters
-        ----------
-        w_in : array-like
-            The input waveform
-        w_out : array-like
-            The filtered waveform
-        """
-        w_out[:] = np.nan
-
-        if np.isnan(w_in).any():
-            return
-
-        if len(t0_kern) > len(w_in):
-            raise DSPFatal('The filter is longer than the input waveform')
-
-        w_out[:] = np.convolve(w_in, t0_kern)[:len(w_in)]
-
-    return t0_filter_out
diff --git a/src/pygama/dsp/_processors/fftw.py b/src/pygama/dsp/_processors/fftw.py
deleted file mode 100644
index c851bb335..000000000
--- a/src/pygama/dsp/_processors/fftw.py
+++ /dev/null
@@ -1,123 +0,0 @@
-import numpy as np
-from numba import guvectorize
-from pyfftw import FFTW
-
-
-def dft(buf_in, buf_out):
-    """
-    Perform discrete Fourier transforms using the FFTW library.  FFTW optimizes
-    the FFT algorithm based on the size of the arrays, with SIMD parallelized
-    commands.  This optimization requires initialization, so this is a factory
-    function that returns a Numba gufunc that performs the FFT.  FFTW works on
-    fixed memory buffers, so you must tell it what memory to use ahead of time.
-    When using this with ProcessingChain, to ensure the correct buffers are used,
-    call ProcessingChain.get_variable('var_name') to give it the internal memory
-    buffer directly---with build_dsp, you can just give it the name, and it will
-    automatically happen.  The possible dtypes for the input/outputs are:
-    - float32/float          (size n) -> complex64              (size n/2+1)
-    - float64/double         (size n) -> complex128             (size n/2+1)
-    - float128/longdouble    (size n) -> complex256/clongdouble (size n/2+1)
-    - complex64              (size n) -> complex64              (size n    )
-    - complex128             (size n) -> complex128             (size n    )
-    - complex256/clongdouble (size n) -> complex256/clongdouble (size n    )
-    """
-    try:
-        dft_fun = FFTW(buf_in, buf_out, axes=(-1,), direction='FFTW_FORWARD')
-    except ValueError:
-        raise ValueError("""Incompatible array types/shapes.  Allowed:
-    - float32/float (size n) -> complex64 (size n/2+1)
-    - float64/double (size n) -> complex128 (size n/2+1)
-    - float128/longdouble (size n) -> complex256/clongdouble (size n/2+1)
-    - complex64 (size n) -> complex64 (size n)
-    - complex128 (size n) -> complex128 (size n)
-    - complex256/clongdouble (size n) -> complex256/clongdouble (size n)""")
-
-    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)
-    def dft(wf_in, dft_out):
-        dft_fun(wf_in, dft_out)
-
-    return dft
-
-def inv_dft(buf_in, buf_out):
-    """
-    Perform inverse discrete Fourier transforms using the FFTW library.  FFTW
-    optimizes the FFT algorithm based on the size of the arrays, with SIMD parallelized
-    commands.  This optimization requires initialization, so this is a factory
-    function that returns a Numba gufunc that performs the FFT.  FFTW works on
-    fixed memory buffers, so you must tell it what memory to use ahead of time.
-    When using this with ProcessingChain, to ensure the correct buffers are used,
-    call ProcessingChain.get_variable('var_name') to give it the internal memory
-    buffer directly---with build_dsp, you can just give it the name, and it will
-    automatically happen.  The possible dtypes for the input/outputs are:
-    - complex64              (size n/2+1) -> float32/float          (size n)
-    - complex128             (size n/2+1) -> float64/double         (size n)
-    - complex256/clongdouble (size n/2+1) -> float128/longdouble    (size n)
-    - complex64              (size n    ) -> complex64              (size n)
-    - complex128             (size n    ) -> complex128             (size n)
-    - complex256/clongdouble (size n    ) -> complex256/clongdouble (size n)
-    """
-    try:
-        idft_fun = FFTW(buf_in, buf_out, axes=(-1,), direction='FFTW_BACKWARD')
-    except ValueError:
-        raise ValueError("""Incompatible array types/shapes.  Allowed:
-        - complex64 (size n/2+1) -> float32/float (size n)
-        - complex128 (size n/2+1) -> float64/double (size n)
-        - complex256/clongdouble (size n/2+1) -> float128/longdouble (size n)
-        - complex64 (size n) -> complex64 (size n)
-        - complex128 (size n) -> complex128 (size n)
-        - complex256/clongdouble (size n) -> complex256/clongdouble (size n)""")
-
-    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)
-    def inv_dft(wf_in, dft_out):
-        idft_fun(wf_in, dft_out)
-
-    return inv_dft
-
-def psd(buf_in, buf_out):
-    """
-    Perform discrete Fourier transforms using the FFTW library, and use it to get
-    the power spectral density.  FFTW optimizes the FFT algorithm based on the
-    size of the arrays, with SIMD parallelized commands.  This optimization
-    requires initialization, so this is a factory function that returns a Numba gufunc
-    that performs the FFT.  FFTW works on fixed memory buffers, so you must
-    tell it what memory to use ahead of time.  When using this with ProcessingChain,
-    to ensure the correct buffers are used, call ProcessingChain.get_variable('var_name')
-    to give it the internal memory buffer directly---with build_dsp, you can just
-    give it the name, and it will automatically happen.  The possible dtypes for the
-    input/outputs are:
-    - complex64              (size n) -> float32/float       (size n    )
-    - complex128             (size n) -> float64/double      (size n    )
-    - complex256/clongdouble (size n) -> float128/longdouble (size n    )
-    - float32/float          (size n) -> float32/float       (size n/2+1)
-    - float64/double         (size n) -> float64/double      (size n/2+1)
-    - float128/longdouble    (size n) -> float128/longdouble (size n/2+1)
-    """
-
-    # build intermediate array for the dft, which will be abs'd to get the PSD
-    buf_dft = np.ndarray(buf_out.shape, np.dtype('complex' + str(buf_out.dtype.itemsize * 16)))
-    try:
-        dft_fun = FFTW(buf_in, buf_dft, axes=(-1,), direction='FFTW_FORWARD')
-    except ValueError:
-        raise ValueError("""Incompatible array types/shapes.  Allowed:
-        - complex64 (size n) -> float32/float (size n)
-        - complex128 (size n) -> float64/double (size n)
-        - complex256/clongdouble (size n) -> float128/longdouble (size n)
-        - float32/float (size n) -> float32/float (size n/2+1)
-        - float64/double (size n) -> float64/double (size n/2+1)
-        - float128/longdouble (size n) -> float128/longdouble (size n/2+1)""")
-
-    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)
-    def psd(wf_in, psd_out):
-        dft_fun(wf_in, buf_dft)
-        np.abs(buf_dft, psd_out)
-
-    return psd
diff --git a/src/pygama/dsp/_processors/fixed_time_pickoff.py b/src/pygama/dsp/_processors/fixed_time_pickoff.py
deleted file mode 100644
index c40036085..000000000
--- a/src/pygama/dsp/_processors/fixed_time_pickoff.py
+++ /dev/null
@@ -1,47 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32[:])",
-              "void(float64[:], float64, float64[:])"],
-             "(n),()->()", nopython=True, cache=True)
-def fixed_time_pickoff(w_in, t_in, a_out):
-    """
-    Pick off the waveform value at the provided index.  If the
-    provided index is out of range, return NaN.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    t_in : int
-        The waveform index to pick off
-    a_out : float
-        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",
-            "prereqs": ["wf_trap", "tp_0"]
-        }
-    """
-    a_out[0] = np.nan
-
-    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 int(t_in) < 0 or int(t_in) >= len(w_in):
-        return
-
-    a_out[0] = w_in[int(t_in)]
diff --git a/src/pygama/dsp/_processors/get_multi_local_extrema.py b/src/pygama/dsp/_processors/get_multi_local_extrema.py
deleted file mode 100644
index 939cd5b3a..000000000
--- a/src/pygama/dsp/_processors/get_multi_local_extrema.py
+++ /dev/null
@@ -1,91 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32[:], float32[:], float32[:], float32[:], float32[:])",
-              "void(float64[:], float64, float64[:], float64[:],float64[:], float64[:], float64[:])"],
-             "(n),(),(m),(m),(),(),()", nopython=True, cache=True)
-def get_multi_local_extrema(w_in, a_delta_in, vt_max_out, vt_min_out, n_max_out, n_min_out, flag_out):
-    """
-    Get lists of indices of the local maxima and minima of data
-    The "local" extrema are those maxima / minima that have heights / depths of
-    at least a_delta_in.
-    Converted from MATLAB script at: http://billauer.co.il/peakdet.html
-
-    Parameters
-    ----------
-    w_in : array-like
-        The array of data within which extrema will be found
-    a_delta_in : scalar
-        The absolute level by which data must vary (in one direction) about an
-        extremum in order for it to be tagged
-    vt_max_out, vt_min_out : array-like, array-like
-        Arrays of fixed length (padded with nans) that hold the indices of
-        the identified local maxima and minima
-    n_max_out, n_min_out : scalar, scalar
-        The number of maxima and minima found in a waveform
-    flag_out : scalar
-        Returns 1 if there is only one maximum and it is a simple waveform,
-        Returns 0 if there are no peaks, or multiple peaks in a waveform
-    """
-
-    # prepare output
-
-    vt_max_out[:]= np.nan
-    vt_min_out[:] = np.nan
-    n_max_out[0] = np.nan
-    n_min_out[0] = np.nan
-    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
-
-    if (not len(vt_max_out)<len(w_in) or not len(vt_min_out)<len(w_in)):
-        raise DSPFatal('The length of your return array must be smaller than the length of your waveform')
-    if (not a_delta_in >= 0):
-        raise DSPFatal('a_delta_in must be positive')
-
-    # now loop over data
-
-    imax, imin = 0, 0
-    find_max = True
-    for i in range(len(w_in)):
-
-        if w_in[i] > w_in[imax]: imax = i
-        if w_in[i] < w_in[imin]: imin = i
-
-        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)):
-                vt_max_out[int(n_max_counter)]=imax
-                n_max_counter += 1
-                imin = i
-                find_max = False
-        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)):
-                vt_min_out[int(n_min_counter)] = imin
-                n_min_counter += 1
-                imax = i
-                find_max = True
-
-    # set output
-    n_max_out[0] = n_max_counter
-    n_min_out[0] = n_min_counter
-
-    if n_max_out[0] == 1:
-        flag_out[0] = 1
-    else:
-        flag_out[0] = 0
diff --git a/src/pygama/dsp/_processors/min_max.py b/src/pygama/dsp/_processors/min_max.py
deleted file mode 100644
index 5fae1461a..000000000
--- a/src/pygama/dsp/_processors/min_max.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32[:], float32[:], float32[:], float32[:])",
-              "void(float64[:], float64[:], float64[:], float64[:], float64[:])"],
-             "(n)->(),(),(),()", nopython=True, cache=True)
-def min_max(w_in, t_min, t_max, a_min, a_max):
-    """
-    Find the value and index of the minimum and maximum values
-    in the waveform.  Note that the first instance of each extremum
-    in the waveform will be returned.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    t_min : int
-        The index of the minimum value
-    t_max : int
-        The index of the maximum value
-    a_min : float
-        The minimum value
-    a_max : float
-        The maximum value
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "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"],
-            "prereqs": ["waveform"]
-        }
-    """
-    a_min[0] = np.nan
-    a_max[0] = np.nan
-    t_min[0] = np.nan
-    t_max[0] = np.nan
-
-    if np.isnan(w_in).any():
-        return
-
-    min_index = 0
-    max_index = 0
-
-    for i in range(0, len(w_in), 1):
-        if w_in[i] < w_in[min_index]:
-            min_index = i
-        if w_in[i] > w_in[max_index]:
-            max_index = i
-
-    a_min[0] = w_in[min_index]
-    a_max[0] = w_in[max_index]
-    t_min[0] = float(min_index)
-    t_max[0] = float(max_index)
diff --git a/src/pygama/dsp/_processors/moving_windows.py b/src/pygama/dsp/_processors/moving_windows.py
deleted file mode 100644
index a594b2d27..000000000
--- a/src/pygama/dsp/_processors/moving_windows.py
+++ /dev/null
@@ -1,206 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32[:])",
-              "void(float64[:], float64, float64[:])"],
-             "(n),()->(n)", nopython=True, cache=True)
-def moving_window_left(w_in, length, w_out):
-    '''
-    Applies a moving average window to the waveform from the left, assumes that the baseline is at 0.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    length : float
-        Length of the moving window to be applied
-    w_out : array-like
-        Output waveform after moving window applied
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_mw": {
-            "function": "moving_window_left",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_pz", "96*ns", "wf_mw"],
-            "unit": "ADC",
-            "prereqs": ["wf_pz"]
-        }
-    '''
-
-    w_out[:] = np.nan
-
-    if (np.isnan(w_in).any()):
-        return
-
-    if (not length >= 0 or not length< len(w_in)):
-        raise DSPFatal('length is out of range, must be between 0 and the length of the waveform')
-
-    w_out[0] = w_in[0]
-    for i in range(1, int(length)):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[0]) / length
-    for i in range(int(length), len(w_in)):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-int(length)]) / length
-
-
-@guvectorize(["void(float32[:], float32, float32[:])",
-              "void(float64[:], float64, float64[:])"],
-             "(n),()->(n)", nopython=True, cache=True)
-def moving_window_right(w_in, length, w_out):
-    '''
-    Applies a moving average window to the waveform from the right.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    length : float
-        Length of the moving window to be applied
-    w_out : array-like
-        Output waveform after moving window applied
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_mw": {
-            "function": "moving_window_right",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_pz", "96*ns", "wf_mw"],
-            "unit": "ADC",
-            "prereqs": ["wf_pz"]
-        }
-    '''
-
-    w_out[:] = np.nan
-
-    if (np.isnan(w_in).any()):
-        return
-
-    if (not length >= 0 or not length< len(w_in)):
-        raise DSPFatal('length is out of range, must be between 0 and the length of the waveform')
-
-    w_out[-1]= w_in[-1]
-    for i in range(1, int(length),1):
-        w_out[len(w_in)-1-i] = w_out[len(w_in)-i] + (w_in[len(w_in)-1-i]-w_out[-1])/length
-    for i in range(int(length), len(w_in), 1):
-        w_out[len(w_in)-1-i] = w_out[len(w_in)-i] + (w_in[len(w_in)-1-i] - w_in[len(w_in)-1-i+int(length)])/length
-
-
-@guvectorize(["void(float32[:], float32, float32, int32, float32[:])",
-              "void(float64[:], float64, float64, int32, float64[:])"],
-             "(n),(),(),()->(n)", nopython=True, cache=True)
-def moving_window_multi(w_in, length, num_mw, mw_type,w_out):
-    """
-    Apply a series of moving-average windows to the waveform, alternating
-    its application between the left and the right.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    length : float
-        Length of the moving window to be applied
-    num_mw : int
-        The number of moving windows
-    mw_type : int
-        0 = alternate moving windows right and left
-        1 = only left
-        2 = only right
-    w_out : array-like
-        The windowed waveform
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "curr_av": {
-            "function": "moving_window_multi",
-            "module": "pygama.dsp.processors",
-            "args": ["curr", "96*ns", "3", "0", "curr_av"],
-            "unit": "ADC/sample",
-            "prereqs": ["curr"]
-        }
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any():
-        return
-
-    if np.floor(length) != length:
-        raise DSPFatal('The length of the moving window must be an integer')
-
-    if np.floor(num_mw) != num_mw:
-        raise DSPFatal('The number of moving windows must be an integer')
-
-    if int(length) < 0 or int(length) >= len(w_in):
-        raise DSPFatal('The length of the moving window is out of range')
-
-    if int(num_mw) < 0:
-        raise DSPFatal('The number of moving windows much be positive')
-
-    w_buf = w_in.copy()
-    for i in range(0, int(num_mw), 1):
-        if ((i % 2 == 1) & (mw_type==0))|(mw_type==2):
-            w_out[-1]= w_buf[-1]
-            for i in range(1, int(length),1):
-                w_out[len(w_buf)-1-i] = w_out[len(w_buf)-i] + (w_buf[len(w_buf)-1-i]-w_out[-1])/length
-            for i in range(int(length), len(w_buf), 1):
-                w_out[len(w_buf)-1-i] = w_out[len(w_buf)-i] + (w_buf[len(w_buf)-1-i] - w_buf[len(w_buf)-1-i+int(length)])/length
-        else:
-            w_out[0] = w_buf[0]
-            for i in range(1, int(length)):
-                w_out[i] = w_out[i-1] + (w_buf[i] - w_buf[0]) / length
-            for i in range(int(length), len(w_buf)):
-                w_out[i] = w_out[i-1] + (w_buf[i] - w_buf[i-int(length)]) / length
-        w_buf = w_out.copy()
-
-
-
-
-@guvectorize(["void(float32[:], float32, float32[:])",
-              "void(float64[:], float64, float64[:])"],
-             "(n),(),(m)", nopython=True, cache=True)
-def avg_current(w_in, length, w_out):
-    """
-    Calculate the derivative of a WF, averaged across n samples. Dimension of
-    deriv should be len(wf) - n
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    length : float
-        Length of the moving window to be applied
-    w_out : array-like
-        Output waveform after derivation
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "curr": {
-            "function": "avg_current",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_pz", 1, "curr(len(wf_pz)-1, f)"],
-            "unit": "ADC/sample",
-            "prereqs": ["wf_pz"]
-        }
-    """
-
-    w_out[:] = np.nan
-
-    if (np.isnan(w_in).any()):
-        return
-
-    if (not length >= 0 or not length< len(w_in)):
-        raise DSPFatal('length is out of range, must be between 0 and the length of the waveform')
-
-
-    w_out[:] = w_in[int(length):] - w_in[:-int(length)]
-    w_out/=length
diff --git a/src/pygama/dsp/_processors/multi_t_filter.py b/src/pygama/dsp/_processors/multi_t_filter.py
deleted file mode 100644
index 4ebd2b81d..000000000
--- a/src/pygama/dsp/_processors/multi_t_filter.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp._processors.time_point_thresh import time_point_thresh
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:],float32[:],float32[:])",
-              "void(float64[:],float64[:],float64[:])"],
-             "(n),(n) -> (n)", nopython=True, cache=True)
-def remove_duplicates(t_in, vt_min_in, t_out):
-    """
-    time_point_thresh has issues with afterpulsing in waveforms that causes an
-    aferpulse peak's tp0 to be sent to 0 or the same index as the tp0 for the
-    first pulse.  This only happens when the relative minimum between the first
-    pulse and the afterpulse is greater than the threshold. So, we sweep
-    through the array again to ensure there are no duplicate indices. If there
-    are duplicate indices caused by a misidentified tp0 of an afterpulse, we
-    replace its index by that of the corresponding minimum found using the
-    get_multi_local_extrema function. It also checks to make sure that the
-    maximum of a waveform isn't right at index 0.
-
-    Parameters
-    ----------
-    t_in : array-like
-        The array of indices that we want to remove duplicates from
-    vt_min_in : array-like
-        List of indices of minima that we want to replace duplicates in t_out with
-    t_out : array-like
-        The array we want to return that will have no duplicate indices in it
-    """
-    # initialize arrays
-    t_out[:] = np.nan
-
-    # checks
-    if (np.isnan(t_in).all() and np.isnan(vt_min_in).all()): # we pad these with NaNs, so only return if there is nothing to analyze
-        return
-
-    # check if any later indexed values are equal to the earliest instance
-    k=0
-    for index1 in range(len(t_in)):
-        for index2 in range(len(t_in[index1+1:])):
-            if t_in[index1] == t_in[index2+index1+1]:
-                t_out[index2+index1+1] = vt_min_in[k]
-        k+=1 # this makes sure that the index of the misidentified afterpulse tp0 is replaced with the correct corresponding minimum
-
-    # Fill up the output with the rest of the values from the input that weren't repeats
-    for index in range(len(t_in)):
-        if (np.isnan(t_out[index]) and not np.isnan(t_in[index])):
-            t_out[index] = t_in[index]
-
-    # makes sure that the first maximum found isn't the start of the waveform
-    if not np.isnan(t_out[0]):
-        if int(t_out[0]) == 0:
-            t_out[:] = np.append(t_out[1:],np.nan)
-
-
-
-@guvectorize(["void(float32[:], float32[:], float32[:], float32[:], float32[:])",
-              "void(float64[:], float64[:], float64[:], float64[:], float64[:])"],
-             "(n),(),(m),(m),(m)", forceobj=True, cache=True)
-def multi_t_filter(w_in, a_threshold_in, vt_max_in, vt_min_in, t_out):
-    """
-    Gets list of indices of the start of leading edges of multiple peaks within a waveform.
-    Is built to handle afterpulses/delayed cross talk and trains of pulses.
-    The multi_t_filter works by calling the vectorized functions
-    "get_multi_local_extrema" which returns a list of the maxima and minima in a waveform,
-    and then the list of maxima is fed into "time_point_thresh" which returns
-    the final times that waveform is less than a specified threshold.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The array of data within which the list of tp0s will be found
-    a_threshold_in : scalar
-        Threshold to search for using time_point_thresh
-    vt_max_in : array-like
-        The array of max positions for each wf
-    vt_min_in : array-like
-        The array of min positions for each wf
-    t_out : array-like
-        Output array of fixed length (padded with nans) that hold the indices of
-        the identified initial rise times of peaks in the signal
-    """
-
-    # initialize arrays, padded with the elements we want
-    t_out[:] = np.nan
-
-    # checks
-    if (np.isnan(w_in).any() or np.isnan(a_threshold_in)):
-        return
-    if (np.isnan(vt_max_in).all() and np.isnan(vt_min_in).all()):
-        return
-    if (not len(t_out)<=len(w_in)):
-        raise DSPFatal('The length of your return array must be smaller than the length of your waveform')
-
-    # Initialize an intermediate array to hold the tp0 values before we remove duplicates from it
-    intermediate_t_out = np.full_like(t_out, np.nan, dtype=np.float32)
-
-    # Go through the list of maxima, calling time_point_thresh (the refactored version ignores the nan padding)
-    time_point_thresh(w_in, a_threshold_in, vt_max_in, 0, intermediate_t_out)
-
-    # Remove duplicates from the t_out list
-    remove_duplicates(intermediate_t_out, vt_min_in, t_out)
diff --git a/src/pygama/dsp/_processors/optimize.py b/src/pygama/dsp/_processors/optimize.py
deleted file mode 100644
index cfe8082ef..000000000
--- a/src/pygama/dsp/_processors/optimize.py
+++ /dev/null
@@ -1,155 +0,0 @@
-import numpy as np
-from iminuit import Minuit
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-from .pole_zero import double_pole_zero, pole_zero
-
-
-class Model:
-    """
-    The model class containing the function to minimize.
-    """
-    errordef = Minuit.LEAST_SQUARES
-
-    # the constructor
-    def __init__(self, func, w_in, baseline, beg, end):
-        self.func = func
-        self.x    = np.arange(beg, end)
-        self.y    = np.asarray(w_in, dtype=np.float64) - baseline
-        self.beg  = beg
-        self.end  = end
-
-    # the function to minimize
-    def __call__(self, args):
-        y_pz = self.func(self.y, *args)[self.beg:self.end]
-        return np.abs(np.sum(self.x) * np.sum(y_pz) - len(self.x) * np.sum(self.x * y_pz))
-
-@guvectorize(["void(float32[:], float32, float32, float32, float32, float32[:])",
-              "void(float64[:], float64, float64, float64, float64, float64[:])"],
-             "(n),(),(),(),()->()", forceobj=True)
-def optimize_1pz(w_in, a_baseline_in, t_beg_in, t_end_in, p0_in, val0_out):
-    """
-    Find the optimal, single pole-zero cancellation's parameter
-    by minimizing the slope in the waveform's specified time range.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    a_baseline_in : float
-        The resting baseline
-    t_beg_in : int
-        The lower bound's index for the time range over
-        which to optimize the pole-zero cancellation
-    t_end_in : int
-        The upper bound's index for the time range over
-        which to optimize the pole-zero cancellation
-    p0_in : float
-        The initial guess of the optimal time constant
-    val0_out : float
-        The output value of the best-fit time constant
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "tau0": {
-            "function": "optimize_1pz",
-            "module": "pygama.dsp.processors",
-            "args": ["waveform", "baseline", "0", "20*us", "500*us", "tau0"],
-            "prereqs": ["waveform", "baseline"],
-            "unit": "us"
-        }
-    """
-    val0_out[0] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(a_baseline_in) or np.isnan(t_beg_in) or np.isnan(t_end_in) or\
-       np.isnan(p0_in):
-        return
-
-    if not np.floor(t_beg_in) == t_beg_in or\
-       not np.floor(t_end_in) == t_end_in:
-        raise DSPFatal('The waveform index is not an integer')
-
-    if int(t_beg_in) < 0 or int(t_beg_in) > len(w_in) or\
-       int(t_end_in) < 0 or int(t_end_in) > len(w_in):
-        raise DSPFatal('The waveform index is out of range')
-
-    m = Minuit(Model(pole_zero, w_in, a_baseline_in, int(t_beg_in), int(t_end_in)), [p0_in])
-    m.print_level = -1
-    m.strategy = 1
-    m.errordef = Minuit.LEAST_SQUARES
-    m.migrad()
-    val0_out[0] = m.values[0]
-
-@guvectorize(["void(float32[:], float32, float32, float32, float32, float32, float32, float32[:], float32[:], float32[:])",
-              "void(float64[:], float64, float64, float64, float64, float64, float64, float64[:], float64[:], float64[:])"],
-             "(n),(),(),(),(),(),()->(),(),()", forceobj=True)
-def optimize_2pz(w_in, a_baseline_in, t_beg_in, t_end_in, p0_in, p1_in, p2_in, val0_out, val1_out, val2_out):
-    """
-    Find the optimal, double pole-zero cancellation's parameters
-    by minimizing the slope in the waveform's specified time range.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    a_baseline_in : float
-        The resting baseline
-    t_beg_in : int
-        The lower bound's index for the time range over
-        which to optimize the pole-zero cancellation
-    t_end_in : int
-        The upper bound's index for the time range over
-        which to optimize the pole-zero cancellation
-    p0_in : float
-        The initial guess of the optimal, longer time constant
-    p1_in : float
-        The initial guess of the optimal, shorter time constant
-    p2_in : float
-        The initial guess of the optimal fraction
-    val0_out : float
-        The output value of the best-fit, longer time constant
-    val1_out : float
-        The output value of the best-fit, shorter time constant
-    val2_out : float
-        The output value of the best-fit fraction
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "tau1, tau2, frac": {
-            "function": "optimize_2pz",
-            "module": "pygama.dsp.processors",
-            "args": ["waveform", "baseline", "0", "20*us", "500*us", "20*us", "0.02", "tau1", "tau2", "frac"],
-            "prereqs": ["waveform", "baseline"],
-            "unit": "us"
-        }
-    """
-    val0_out[0] = np.nan
-    val1_out[0] = np.nan
-    val2_out[0] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(a_baseline_in) or np.isnan(t_beg_in) or np.isnan(t_end_in) or\
-       np.isnan(p0_in) or np.isnan(p1_in) or np.isnan(p2_in):
-        return
-
-    if not np.floor(t_beg_in) == t_beg_in or\
-       not np.floor(t_end_in) == t_end_in:
-        raise DSPFatal('The waveform index is not an integer')
-
-    if int(t_beg_in) < 0 or int(t_beg_in) > len(w_in) or\
-       int(t_end_in) < 0 or int(t_end_in) > len(w_in):
-        raise DSPFatal('The waveform index is out of range')
-
-    m = Minuit(Model(double_pole_zero, w_in, a_baseline_in, int(t_beg_in), int(t_end_in)), [p0_in, p1_in, p2_in])
-    m.print_level = -1
-    m.strategy = 1
-    m.errordef = Minuit.LEAST_SQUARES
-    m.migrad()
-    val0_out[0] = m.values[0]
-    val1_out[0] = m.values[1]
-    val2_out[0] = m.values[2]
diff --git a/src/pygama/dsp/_processors/param_lookup.py b/src/pygama/dsp/_processors/param_lookup.py
deleted file mode 100644
index 47e767edc..000000000
--- a/src/pygama/dsp/_processors/param_lookup.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import numpy as np
-from numba import from_dtype, guvectorize, types
-
-
-def param_lookup(param_dict, default_val, dtype):
-    """
-    Generate the ufunc lookup(channel, val), which returns a NumPy array of
-    values corresponding to various channels that are looked up in the provided
-    param_dict.  If there is no key, use default_val instead.
-    """
-    out_type = from_dtype(np.dtype(dtype))
-
-    # convert types to avoid any necessity of casting
-    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)
-    def lookup(channel, val):
-        """
-        Look up a value for the provided channel from a dictionary provided at
-        compile time.
-        """
-        val[0] = param_dict.get(channel, default_val)
-
-    return lookup
diff --git a/src/pygama/dsp/_processors/pole_zero.py b/src/pygama/dsp/_processors/pole_zero.py
deleted file mode 100644
index 3b34212d7..000000000
--- a/src/pygama/dsp/_processors/pole_zero.py
+++ /dev/null
@@ -1,120 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32[:])",
-              "void(float64[:], float64, float64[:])"],
-             "(n),()->(n)", nopython=True, cache=True)
-def pole_zero(w_in, t_tau, w_out):
-    """
-    Apply a pole-zero cancellation using the provided time
-    constant to the waveform.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    t_tau : float
-        The time constant of the exponential to be deconvolved
-    w_out : array-like
-        The pole-zero cancelled waveform
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_pz": {
-            "function": "pole_zero",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_bl", "400*us", "wf_pz"],
-            "unit": "ADC",
-            "prereqs": ["wf_bl"]
-        }
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(t_tau):
-        return
-
-    const = np.exp(-1 / t_tau)
-    w_out[0] = w_in[0]
-    for i in range(1, len(w_in), 1):
-        w_out[i] = w_out[i-1] + w_in[i] - w_in[i-1] * const
-
-@guvectorize(["void(float32[:], float32, float32, float32, float32[:])",
-              "void(float64[:], float64, float64, float64, float64[:])"],
-             "(n),(),(),()->(n)", nopython=True, cache=True)
-def double_pole_zero(w_in, t_tau1, t_tau2, frac, w_out):
-    """
-    Apply a double pole-zero cancellation using the provided time
-    constants to the waveform.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    t_tau1 : float
-        The time constant of the first exponential to be deconvolved
-    t_tau2 : float
-        The time constant of the second exponential to be deconvolved
-    frac : float
-        The fraction which the second exponential contributes
-    w_out : array-like
-        The pole-zero cancelled waveform
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_pz": {
-            "function": "double_pole_zero",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_bl", "400*us", "20*us", "0.02", "wf_pz"],
-            "unit": "ADC",
-            "prereqs": ["wf_bl"]
-        }
-
-    Notes
-    -----
-    This algorithm is an IIR filter to deconvolve the function
-
-    .. math:: s(t) = A[frac*\\exp\\left(-\\frac{t}{t_{tau2}}\\right) + (1-frac)*\\exp\\left(-\\frac{t}{t_{tau1}}\\right)]
-
-    into a single step function of amplitude A.
-    This filter is derived by z-transforming the input (:math:`s(t)`) and output (step function)
-    signals, and then determining the transfer function. For shorthand, define :math:`a=\\exp\\left(-\\frac{1}{t_{tau1}}\\right)`
-    and :math:`b=\\exp\\left(-\\frac{1}{t_{tau2}}\\right)`, the transfer function is then:
-
-    .. math:: H(z) =  \\frac{1-(a+b)z^{-1}+abz^{-2}}{1+(fb-fa-b-1)z^{-1}-(fb-fa-b)z^{-2}}
-
-    By equating the transfer function to the ratio of output to input waveforms :math:`H(z) = \\frac{w_{out}(z)}{w_{in}(z)}`
-    and then taking the inverse z-transform yields the filter as run in the function, where f is the frac parameter:
-
-    .. math::
-        w_{out}[n] = &w_{in}[n]-(a+b)w_{in}[n-1]+(ab)w_{in}[n-2]\\\\
-        &-(fb-fa-b-1)w_{out}[n-1]+(fb-fa-b)w_{out}[n-2]
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(t_tau1) or np.isnan(t_tau2) or np.isnan(frac):
-        return
-    if (len(w_in)<=3):
-        raise DSPFatal('The length of the waveform must be larger than 3 for the filter to work safely')
-
-    a = np.exp(-1 / t_tau1)
-    b = np.exp(-1 / t_tau2)
-
-    transfer_denom_1 = (frac*b-frac*a-b-1)
-    transfer_denom_2 = -1*(frac*b-frac*a-b)
-    transfer_num_1 = -1*(a+b)
-    transfer_num_2 = a*b
-
-    w_out[0] = w_in[0]
-    w_out[1] = w_in[1]
-    w_out[2] = w_in[2]
-
-    for i in range(2, len(w_in), 1):
-        w_out[i] = w_in[i] + transfer_num_1*w_in[i-1] + transfer_num_2*w_in[i-2]\
-            - transfer_denom_1*w_out[i-1] - transfer_denom_2*w_out[i-2]
diff --git a/src/pygama/dsp/_processors/presum.py b/src/pygama/dsp/_processors/presum.py
deleted file mode 100644
index c1e6430ff..000000000
--- a/src/pygama/dsp/_processors/presum.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32[:])",
-              "void(float64[:], float64[:])"],
-             "(n),(m)", nopython=True, cache=True)
-def presum(w_in, w_out):
-    """
-    Presum the waveform.  Combine bins in chunks of len(w_in) / len(w_out),
-    which is hopefully an integer.  If it isn't, then some samples at the end
-    will be omitted.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    w_out : array-like
-        The output waveform
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any():
-        return
-
-    ps_fact = len(w_in) // len(w_out)
-    for i in range(0, len(w_out), 1):
-        j0 = i * ps_fact
-        w_out[i] = w_in[j0]/ps_fact
-        for j in range(j0 + 1, j0 + ps_fact, 1):
-            w_out[i] += w_in[j]/ps_fact
diff --git a/src/pygama/dsp/_processors/pulse_injector.py b/src/pygama/dsp/_processors/pulse_injector.py
deleted file mode 100644
index 5959d9e54..000000000
--- a/src/pygama/dsp/_processors/pulse_injector.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from math import exp, log
-
-import numpy as np
-from numba import guvectorize
-
-
-@guvectorize(["void(float32[:], float32, float32, float32, float32, float32[:])",
-              "void(float64[:], float64, float64,float64,float64,float64[:])"],
-              "(n),(),(),(), ()->(n)", nopython=True, cache=True)
-
-def inject_sig_pulse(wf_in,t0,rt,A,decay, wf_out):
-    """
-    Inject sigmoid pulse into existing waveform to simulate pileup
-    """
-
-
-    wf_out[:] = np.nan
-
-    if np.isnan(wf_in).any() or np.isnan(rt) or np.isnan(t0) or np.isnan(A) or np.isnan(decay):
-        return
-
-    rise = 4*log(99)/rt
-
-    wf_out[:] = wf_in[:]
-    for T in range(len(wf_out)):
-        wf_out[T] = wf_out[T]+ (A/(1+exp((-rise)*(T-(t0+rt/2))))*exp(-(1/decay)*(T-t0)))
-
-@guvectorize(["void(float32[:], float32, float32, float32, float32, float32[:])",
-              "void(float64[:], float64, float64,float64,float64,float64[:])"],
-              "(n),(),(),(), ()->(n)", nopython=True, cache=True)
-
-def inject_exp_pulse(wf_in,t0,rt,A,decay, wf_out):
-    """
-    Inject exponential pulse into existing waveform to simulate pileup
-    """
-
-    wf_out[:] = np.nan
-
-    if np.isnan(wf_in).any() or np.isnan(rt) or np.isnan(t0) or np.isnan(A) or np.isnan(decay):
-        return
-
-    wf_out[:] = wf_in[:]
-    for T in range(len(wf_out)):
-        if (t0<= T)& (T<= t0+rt):
-            wf_out[T] += (A*exp((T-t0-rt)/(rt))*exp(-(1/decay)*(T-t0)))
-        elif (T>t0+rt):
-            wf_out[T] += (A*exp(-(1/decay)*(T-t0)))
diff --git a/src/pygama/dsp/_processors/saturation.py b/src/pygama/dsp/_processors/saturation.py
deleted file mode 100644
index 9f800ca93..000000000
--- a/src/pygama/dsp/_processors/saturation.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32[:], float32[:])",
-              "void(float64[:], float64, float64[:], float64[:])"],
-             "(n),()->(),()", nopython=True, cache=True)
-def saturation(w_in, bit_depth_in, n_lo_out, n_hi_out):
-    """
-    Count the number of samples in the waveform that are
-    saturated at the minimum and maximum possible values based
-    on the bit depth.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    bit_depth_in : int
-        The bit depth of the analog-to-digital converter
-    n_lo_out : int
-        The output number of samples at the minimum
-    n_hi_out : int
-        The output number of samples at the maximum
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "sat_lo, sat_hi": {
-            "function": "saturation",
-            "module": "pygama.dsp.processors",
-            "args": ["waveform", "16", "sat_lo", "sat_hi"],
-            "unit": "ADC",
-            "prereqs": ["waveform"]
-        }
-    """
-    n_lo_out[0] = np.nan
-    n_hi_out[0] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(bit_depth_in):
-        return
-
-    if not np.floor(bit_depth_in) == bit_depth_in:
-        raise DSPFatal('The bit depth is not an integer')
-
-    if bit_depth_in <= 0:
-        raise DSPFatal('The bit depth is not positive')
-
-    n_lo_out[0] = 0
-    n_hi_out[0] = 0
-    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)):
-            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
deleted file mode 100644
index f67599933..000000000
--- a/src/pygama/dsp/_processors/soft_pileup_corr.py
+++ /dev/null
@@ -1,128 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32, float32[:])",
-              "void(float64[:], float64, float64, float64[:])"],
-             "(n),(),()->(n)", nopython=True, cache=True)
-def soft_pileup_corr(w_in, n_in, tau_in, w_out):
-    """
-    Fit the baseline to an exponential with the provided time
-    constant and then subtract the best-fit function from the
-    entire waveform.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    n_in : int
-        The number of samples at the beginning of the waveform
-        to fit to an exponential
-    tau_in : float
-        The fixed, exponential time constant
-    w_out : array-like
-        The output waveform with the exponential subtracted
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_bl": {
-            "function": "soft_pileup_corr",
-            "module": "pygama.dsp.processors",
-            "args": ["waveform", "1000", "500*us", "wf_bl"],
-            "unit": "ADC",
-            "prereqs": ["waveform"]
-        }
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(n_in) or np.isnan(tau_in):
-        return
-
-    if not np.floor(n_in) == n_in:
-        raise DSPFatal('The number of samples is not an integer')
-
-    if n_in < 2:
-        raise DSPFatal('The number of samples is not enough for a fit')
-
-    if n_in > len(w_in):
-        raise DSPFatal('The number of samples is more than the waveform length')
-
-    s1 = 0.0
-    s2 = 0.0
-    s3 = 0.0
-    s4 = 0.0
-    s5 = 0.0
-    for i in range(0, n_in, 1):
-        s1 += 1.0
-        s2 += np.exp(-1.0 * i / tau_in)
-        s3 += np.exp(-2.0 * i / tau_in)
-        s4 += np.exp(-1.0 * i / tau_in) * w_in[i]
-        s5 += w_in[i]
-    B = (s5 - s2 * (s4 * s1 - s2 * s5) / (s3 * s1 - s2 * s2)) / s1
-    A = (s4 - B * s2) / s3
-    for i in range(0, len(w_in), 1):
-        w_out[i] = w_in[i] - (A * np.exp(-1.0 * i / tau_in) + B)
-
-@guvectorize(["void(float32[:], float32, float32, float32, float32[:])",
-              "void(float64[:], float64, float64, float64, float64[:])"],
-             "(n),(),(),()->(n)", nopython=True, cache=True)
-def soft_pileup_corr_bl(w_in, n_in, tau_in, B_in, w_out):
-    """
-    Fit the baseline to an exponential with the provided time
-    constant and then subtract the best-fit function from the
-    entire waveform.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    n_in : int
-        The number of samples at the beginning of the waveform
-        to fit to an exponential
-    tau_in : float
-        The fixed, exponential time constant
-    B_in : float
-        The fixed, exponential constant
-    w_out : array-like
-        The output waveform with the exponential subtracted
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_bl": {
-            "function": "soft_pileup_corr_bl",
-            "module": "pygama.dsp.processors",
-            "args": ["waveform", "1000", "500*us", "baseline", "wf_bl"],
-            "unit": "ADC",
-            "prereqs": ["waveform", "baseline"]
-        }
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(n_in) or np.isnan(tau_in) or np.isnan(B_in):
-        return
-
-    if not np.floor(n_in) == n_in:
-        raise DSPFatal('The number of samples is not an integer')
-
-    if n_in < 1:
-        raise DSPFatal('The number of samples is not enough for a fit')
-
-    if n_in > len(w_in):
-        raise DSPFatal('The number of samples is more than the waveform length')
-
-    s2 = 0.0
-    s3 = 0.0
-    s4 = 0.0
-    for i in range(0, n_in, 1):
-        s2 += np.exp(-1.0 * i / tau_in)
-        s3 += np.exp(-2.0 * i / tau_in)
-        s4 += np.exp(-1.0 * i / tau_in) * w_in[i]
-    A = (s4 - B_in * s2) / s3
-    for i in range(0, len(w_in), 1):
-        w_out[i] = w_in[i] - (A * np.exp(-1.0 * i / tau_in) + B_in)
diff --git a/src/pygama/dsp/_processors/time_point_thresh.py b/src/pygama/dsp/_processors/time_point_thresh.py
deleted file mode 100644
index 93b65bb48..000000000
--- a/src/pygama/dsp/_processors/time_point_thresh.py
+++ /dev/null
@@ -1,63 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32, float32, float32[:])",
-              "void(float64[:], float64, float64, float64, float64[:])"],
-             "(n),(),(),()->()", nopython=True, cache=True)
-def time_point_thresh(w_in, a_threshold, t_start, walk_forward, t_out):
-    """
-    Find the index where the waveform value crosses the threshold,
-    walking either forward or backward from the starting index.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    a_threshold : float
-        The threshold value
-    t_start : int
-        The starting index
-    walk_forward : int
-        The backward (0) or forward (1) search direction
-    t_out : float
-        The index where the waveform value crosses the threshold
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "tp_0": {
-            "function": "time_point_thresh",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_atrap", "bl_std", "tp_start", 0, "tp_0"],
-            "unit": "ns",
-            "prereqs": ["wf_atrap", "bl_std", "tp_start"]
-        }
-    """
-    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 np.floor(t_start) != t_start:
-        raise DSPFatal('The starting index must be an integer')
-
-    if np.floor(walk_forward) != walk_forward:
-        raise DSPFatal('The search direction must be an integer')
-
-    if int(t_start) < 0 or int(t_start) >= len(w_in):
-        raise DSPFatal('The starting index is out of range')
-
-    if int(walk_forward) == 1:
-        for i in range(int(t_start), len(w_in) - 1, 1):
-            if w_in[i] <= a_threshold < w_in[i+1]:
-                t_out[0] = i
-                return
-    else:
-        for i in range(int(t_start), 1, -1):
-            if w_in[i-1] < a_threshold <= w_in[i]:
-                t_out[0] = i
-                return
diff --git a/src/pygama/dsp/_processors/trap_filters.py b/src/pygama/dsp/_processors/trap_filters.py
deleted file mode 100644
index d2d810f11..000000000
--- a/src/pygama/dsp/_processors/trap_filters.py
+++ /dev/null
@@ -1,238 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], int32, int32, float32[:])",
-              "void(float64[:], int32, int32, float64[:])"],
-             "(n),(),()->(n)", nopython=True, cache=True)
-def trap_filter(w_in, rise, flat, w_out):
-    """
-    Apply a symmetric trapezoidal filter to the waveform.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    rise : int
-        The number of samples averaged in the rise and fall sections
-    flat : int
-        The delay between the rise and fall sections
-    w_out : array-like
-        The filtered waveform
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_tf": {
-            "function": "trap_filter",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_pz", "10*us", "3*us", "wf_tf"],
-            "unit": "ADC",
-            "prereqs": ["wf_pz"]
-        }
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat):
-        return
-
-    if int(rise) < 0:
-        raise DSPFatal('The number of samples in the rise section must be positive')
-
-    if int(flat) < 0:
-        raise DSPFatal('The number of samples in the flat section must be positive')
-
-    if 2 * int(rise) + int(flat) > len(w_in):
-        raise DSPFatal('The trapezoid width is wider than the waveform')
-
-    w_out[0] = w_in[0]
-    for i in range(1, rise, 1):
-        w_out[i] = w_out[i-1] + w_in[i]
-    for i in range(rise, rise + flat, 1):
-        w_out[i] = w_out[i-1] + w_in[i] - w_in[i-rise]
-    for i in range(rise + flat, 2 * rise + flat, 1):
-        w_out[i] = w_out[i-1] + w_in[i] - w_in[i-rise] - w_in[i-rise-flat]
-    for i in range(2 * rise + flat, len(w_in), 1):
-        w_out[i] = w_out[i-1] + w_in[i] - w_in[i-rise] - w_in[i-rise-flat] + w_in[i-2*rise-flat]
-
-@guvectorize(["void(float32[:], int32, int32, float32[:])",
-              "void(float64[:], int32, int32, float64[:])"],
-             "(n),(),()->(n)", nopython=True, cache=True)
-def trap_norm(w_in, rise, flat, w_out):
-    """
-    Apply a symmetric trapezoidal filter to the waveform, normalized
-    by the number of samples averaged in the rise and fall sections.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    rise : int
-        The number of samples averaged in the rise and fall sections
-    flat : int
-        The delay between the rise and fall sections
-    w_out : array-like
-        The normalized, filtered waveform
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_tf": {
-            "function": "trap_norm",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_pz", "10*us", "3*us", "wf_tf"],
-            "unit": "ADC",
-            "prereqs": ["wf_pz"]
-        }
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat):
-        return
-
-    if int(rise) < 0:
-        raise DSPFatal('The number of samples in the rise section must be positive')
-
-    if int(flat) < 0:
-        raise DSPFatal('The number of samples in the flat section must be positive')
-
-    if 2 * int(rise) + int(flat) > len(w_in):
-        raise DSPFatal('The trapezoid width is wider than the waveform')
-
-    w_out[0] = w_in[0] / rise
-    for i in range(1, rise, 1):
-        w_out[i] = w_out[i-1] + w_in[i] / rise
-    for i in range(rise, rise + flat, 1):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise]) / rise
-    for i in range(rise + flat, 2 * rise + flat, 1):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise] - w_in[i-rise-flat]) / rise
-    for i in range(2 * rise + flat, len(w_in), 1):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise] - w_in[i-rise-flat] + w_in[i-2*rise-flat]) / rise
-
-@guvectorize(["void(float32[:], int32, int32, int32, float32[:])",
-              "void(float64[:], int32, int32, int32, float64[:])"],
-             "(n),(),(),()->(n)", nopython=True, cache=True)
-def asym_trap_filter(w_in, rise, flat, fall, w_out):
-    """
-    Apply an asymmetric trapezoidal filter to the waveform, normalized
-    by the number of samples averaged in the rise and fall sections.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    rise : int
-        The number of samples averaged in the rise section
-    flat : int
-        The delay between the rise and fall sections
-    fall : int
-        The number of samples averaged in the fall section
-    w_out : array-like
-        The normalized, filtered waveform
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_af": {
-            "function": "asym_trap_filter",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_pz", "128*ns", "64*ns", "2*us", "wf_af"],
-            "unit": "ADC",
-            "prereqs": ["wf_pz"]
-        }
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat) or np.isnan(fall):
-        return
-
-    if int(rise) < 0:
-        raise DSPFatal('The number of samples in the rise section must be positive')
-
-    if int(flat) < 0:
-        raise DSPFatal('The number of samples in the flat section must be positive')
-
-    if int(fall) < 0:
-        raise DSPFatal('The number of samples in the fall section must be positive')
-
-    if int(rise) + int(flat) + int(fall) > len(w_in):
-        raise DSPFatal('The trapezoid width is wider than the waveform')
-
-    w_out[0] = w_in[0] / rise
-    for i in range(1, rise, 1):
-        w_out[i] = w_out[i-1] + w_in[i] / rise
-    for i in range(rise, rise + flat, 1):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise]) / rise
-    for i in range(rise + flat, rise + flat + fall, 1):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise]) / rise - w_in[i-rise-flat] / fall
-    for i in range(rise + flat + fall, len(w_in), 1):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise]) / rise - (w_in[i-rise-flat] - w_in[i-rise-flat-fall]) / fall
-
-@guvectorize(["void(float32[:], int32, int32, float32, float32[:])",
-              "void(float64[:], int32, int32, float64, float64[:])"],
-             "(n),(),(),()->()", nopython=True, cache=True)
-def trap_pickoff(w_in, rise, flat, t_pickoff, a_out):
-    """
-    Pick off the value at the provided index of a symmetric trapezoidal
-    filter to the input waveform, normalized by the number of samples averaged
-    in the rise and fall sections.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    rise : int
-        The number of samples averaged in the rise and fall sections
-    flat : int
-        The delay between the rise and fall sections
-    t_pickoff : float
-        The waveform index to pick off
-    a_out : float
-        The output pick-off value of the filtered waveform
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "ct_corr": {
-            "function": "trap_pickoff",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_pz", "1.5*us", 0, "tp_0", "ct_corr"],
-            "unit": "ADC",
-            "prereqs": ["wf_pz", "tp_0"]
-        }
-    """
-    a_out[0] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat) or np.isnan(t_pickoff):
-        return
-
-    if np.floor(t_pickoff) != t_pickoff:
-        raise DSPFatal('The pick-off index must be an integer')
-
-    if int(rise) < 0:
-        raise DSPFatal('The number of samples in the rise section must be positive')
-
-    if int(flat) < 0:
-        raise DSPFatal('The number of samples in the flat section must be positive')
-
-    if 2 * int(rise) + int(flat) > len(w_in):
-        raise DSPFatal('The trapezoid width is wider than the waveform')
-
-    I_1 = 0.
-    I_2 = 0.
-    start_time = int(t_pickoff + 1)
-
-    if not len(w_in) >= start_time >= 2*rise + flat:
-        return
-
-    for i in range(start_time - rise, start_time, 1):
-        I_1 += w_in[i]
-    for i in range(start_time - 2 * rise - flat, start_time - rise - flat, 1):
-        I_2 += w_in[i]
-    a_out[0] = (I_1 - I_2) / rise
diff --git a/src/pygama/dsp/_processors/upsampler.py b/src/pygama/dsp/_processors/upsampler.py
deleted file mode 100644
index a9dfea0e0..000000000
--- a/src/pygama/dsp/_processors/upsampler.py
+++ /dev/null
@@ -1,39 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32[:])",
-              "void(float64[:], float64, float64[:])"],
-             "(n),(),(m)", nopython=True, cache=True)
-
-def upsampler(w_in, upsample, w_out):
-    """
-    Upsamples the waveform by the number specified,
-    a series of moving windows should be applied afterwards for smoothing
-
-    Parameters
-    ----------
-    w_in : array-like
-        waveform to upsample.
-    upsample : float
-        Number of samples to increase each sample to.
-    w_out : array-like
-        Output array for upsampled waveform
-    """
-
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any():
-        return
-
-    if not (upsample>0):
-        raise DSPFatal('Upsample must be greater than 0')
-
-    for t_in in range(0, len(w_in)):
-        t_out  = int(t_in * upsample - np.floor(upsample / 2))
-        for j in range(0, int(upsample)):
-            if (t_out >=0) & (t_out<len(w_out)):
-                w_out[t_out] =  w_in[t_in]
-            t_out +=1
diff --git a/src/pygama/dsp/_processors/wiener_filter.py b/src/pygama/dsp/_processors/wiener_filter.py
deleted file mode 100644
index 9bec0a18a..000000000
--- a/src/pygama/dsp/_processors/wiener_filter.py
+++ /dev/null
@@ -1,119 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-import pygama.lgdo.lh5_store as lh5
-from pygama.dsp.errors import DSPFatal
-
-
-def wiener_filter(file_name_array):
-    """Apply a wiener filter to the waveform.
-
-    Note that this convolution is performed in the frequency domain
-
-    Parameters
-    ----------
-    file_name_array : string
-        Array with path to an lh5 file containing the time domain version
-        of the superpulse in one column and noise waveform in another,
-        the superpulse group must be titled 'spms/processed/superpulse' and
-        the noise waveform must be called 'spms/processed/noise_wf'
-
-    Examples
-    --------
-    .. code-block :: json
-
-        "wf_wiener": {
-            "function": "wiener_filter",
-            "module": "pygama.dsp.processors",
-            "args": ["wf_bl_fft", "wf_wiener(2000,f)"],
-            "unit": "dB",
-            "prereqs": ["wf_bl_fft"],
-            "init_args": ["/path/to/file/wiener.lh5"]
-        }
-    """
-
-    sto = lh5.LH5Store()
-
-    # Check that the file is valid and the data is in the correct format
-
-    try: file_name_array[0]
-    except: raise DSPFatal('init_args must be an array with the filename')
-
-    file_name = file_name_array[0]
-
-    try: f = sto.gimme_file(file_name, 'r')
-    except: raise DSPFatal('File must be a valid lh5 file')
-
-    if 'spms/processed/superpulse' not in f:
-        raise DSPFatal('lh5 file must have \'spms/processed/superpulse\' as a group')
-
-    if 'spms/processed/noise_wf' not in f:
-        raise DSPFatal('lh5 file must have \'spms/processed/noise_wf\' as a group')
-
-    # Read in the data
-
-    superpulse, _ = sto.read_object('spms/processed/superpulse', file_name)
-    superpulse = superpulse.nda
-
-    noise_wf, _ = sto.read_object('spms/processed/noise_wf', file_name)
-    noise_wf = noise_wf.nda
-
-    # Now check that the data are valid
-
-    if len(superpulse) <= 0:
-        raise DSPFatal('The length of the filter must be positive')
-
-    if len(superpulse) != len(noise_wf):
-        raise DSPFatal('The length of the superpulse must be equal to the length of the noise waveform')
-
-    if np.argmax(superpulse) <= 0 or np.argmax(superpulse) > len(superpulse):
-        raise DSPFatal('The index of the maximum of the superpulse must occur within the waveform')
-
-    # Transform these to the frequency domain to eventually create the wiener filter
-
-    fft_superpulse = np.fft.fft(superpulse)
-    fft_noise_wf = np.fft.fft(noise_wf)
-
-    # Create the point spread function for the detector's response
-
-    def PSF(superpulse, fft_superpulse):
-
-        delta = np.zeros_like(superpulse)
-        arg_max = np.argmax(superpulse)
-        delta[arg_max] = np.amax(superpulse)
-
-        return fft_superpulse/np.fft.fft(delta)
-
-    # Now create the wiener filter in the frequency domain
-
-    fft_PSF = PSF(superpulse, fft_superpulse)
-    PSD_noise_wf = fft_noise_wf*np.conj(fft_noise_wf)
-    PSD_superpulse = fft_superpulse*np.conj(fft_superpulse)
-
-    w_filter = (np.conj(fft_PSF))/((fft_PSF*np.conj(fft_PSF))+(PSD_noise_wf/PSD_superpulse))
-
-    # Create a factory function that performs the convolution with the wiener filter, the output is still in the frequency domain
-
-    @guvectorize(["void(complex64[:], complex64[:])",
-                  "void(complex128[:], complex128[:])"],
-                 "(n)->(n)", forceobj=True)
-    def wiener_out(fft_w_in, fft_w_out):
-        """
-        Parameters
-        ----------
-        fft_w_in : array-like
-            The fourier transform input waveform
-        fft_w_out : array-like
-            The filtered waveform, in the frequency domain
-        """
-        fft_w_out[:] = np.nan
-
-        if np.isnan(fft_w_in).any():
-            return
-
-        if len(w_filter) != len(fft_w_in):
-            raise DSPFatal('The filter is not the same length of the input waveform')
-
-        fft_w_out[:] = fft_w_in * w_filter
-
-    return wiener_out
diff --git a/src/pygama/dsp/_processors/windower.py b/src/pygama/dsp/_processors/windower.py
deleted file mode 100644
index feae15521..000000000
--- a/src/pygama/dsp/_processors/windower.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import numpy as np
-from numba import guvectorize
-
-from pygama.dsp.errors import DSPFatal
-
-
-@guvectorize(["void(float32[:], float32, float32[:])",
-              "void(float64[:], float64, float64[:])"],
-             "(n),(),(m)", nopython=True, cache=True)
-def windower(w_in, t0_in, w_out):
-    """
-    Return a shorter sample of the waveform, starting at the
-    specified index.  Note that the length of the output waveform
-    is determined by the length of "w_out" rather than an input
-    parameter.  If the the length of "w_out" plus "t0_in" extends
-    past the end of "w_in" or if "t0_in" is negative, remaining
-    values are padded with NaN.
-
-    Parameters
-    ----------
-    w_in : array-like
-        The input waveform
-    t0_in : int
-        The starting index of the window
-    w_out : array-like
-        The windowed waveform
-    """
-    w_out[:] = np.nan
-
-    if np.isnan(w_in).any() or np.isnan(t0_in):
-        return
-
-    if len(w_out) >= len(w_in):
-        raise DSPFatal('The windowed waveform must be smaller than the input waveform')
-
-    beg = min(int(t0_in), len(w_in))
-    end = max(beg + len(w_out), 0)
-    if beg < 0:
-        w_out[:len(w_out)-end] = np.nan
-        w_out[len(w_out)-end:] = w_in[:end]
-    elif end < len(w_in):
-        w_out[:] = w_in[beg:end]
-    else:
-        w_out[:len(w_in)-beg] = w_in[beg:len(w_in)]
-        w_out[len(w_in)-beg:] = np.nan
diff --git a/src/pygama/dsp/build_dsp.py b/src/pygama/dsp/build_dsp.py
index 583542409..bb1615090 100644
--- a/src/pygama/dsp/build_dsp.py
+++ b/src/pygama/dsp/build_dsp.py
@@ -23,78 +23,107 @@
 log = logging.getLogger(__name__)
 
 
-def build_dsp(f_raw: str, f_dsp: str, dsp_config: str | dict, lh5_tables:
-              list[str] = None, database: str = None, outputs: list[str] =
-              None, n_max: int = np.inf, write_mode: str = 'r', buffer_len: int
-              = 3200, block_width: int = 16, chan_config: dict = None) -> None:
-    """
-    Convert raw-tier LH5 data into dsp-tier LH5 data by running a sequence of
-    processors via the :class:`~.processing_chain.ProcessingChain`.
+def build_dsp(
+    f_raw: str,
+    f_dsp: str,
+    dsp_config: str | dict = None,
+    lh5_tables: list[str] = None,
+    database: str | dict = None,
+    outputs: list[str] = None,
+    n_max: int = np.inf,
+    write_mode: str = None,
+    buffer_len: int = 3200,
+    block_width: int = 16,
+    chan_config: dict[str, str] = None,
+) -> None:
+    """Convert raw-tier LH5 data into dsp-tier LH5 data by running a sequence
+    of processors via the :class:`~.processing_chain.ProcessingChain`.
 
     Parameters
     ----------
-    f_raw : str
-        name of raw LH5 file to read from
-    f_dsp : str
-        name of dsp LH5 file to write to
-    dsp_config : str or dict
-        dict or name of JSON file containing
+    f_raw
+        name of raw-tier LH5 file to read from.
+    f_dsp
+        name of dsp-tier LH5 file to write to.
+    dsp_config
+        :class:`dict` or name of JSON file containing
         :class:`~.processing_chain.ProcessingChain` config. See
-        :func:`~.processing_chain.build_processing_chain` for details
-    lh5_tables : list of str, optional
-        list of HDF5 groups to consider in the input file. If None, process all
-        valid groups
-    database : str, optional
-        name of JSON file containing a parameter database. See
-        :func:`~.processing_chain.build_processing_chain` for details
-    outputs : list of str, optional
+        :func:`~.processing_chain.build_processing_chain` for details.
+    lh5_tables
+        list of HDF5 groups to consider in the input file. If ``None``, process
+        all valid groups.
+    database
+        dictionary or name of JSON file containing a parameter database. See
+        :func:`~.processing_chain.build_processing_chain` for details.
+    outputs
         list of parameter names to write to the output file. If not provided,
-        use list provided under ``"outputs"`` in the DSP configuration file
-    n_max : int, optional
-        Number of waveforms to process. Default all
-    write_mode : {'r', 'a', 'u'}, optional
+        use list provided under ``"outputs"`` in the DSP configuration file.
+    n_max
+        number of waveforms to process.
+    write_mode
+        - ``None`` -- create new output file if it does not exist
         - `'r'` -- delete existing output file with same name before writing
         - `'a'` -- append to end of existing output file
         - `'u'` -- update values in existing output file
-    buffer_len : int, optional, default=3200
-        number of waveforms to read/write from disk at a time
-    block_width : int, optional, default=16
-        number of waveforms to process at a time
-    chan_config : dict, optional
+    buffer_len
+        number of waveforms to read/write from/to disk at a time.
+    block_width
+        number of waveforms to process at a time.
+    chan_config
         contains JSON DSP configuration file names for every table in
-        `lh5_tables`
+        `lh5_tables`.
     """
 
+    if chan_config is not None:
+        # clear existing output files
+        if write_mode == "r":
+            if os.path.isfile(f_dsp):
+                os.remove(f_dsp)
+            write_mode = "a"
+
+        for tb, dsp_config in chan_config.items():
+            log.debug(f"processing table: {tb} with DSP config file {dsp_config}")
+            try:
+                build_dsp(
+                    f_raw,
+                    f_dsp,
+                    dsp_config,
+                    [tb],
+                    database,
+                    outputs,
+                    n_max,
+                    write_mode,
+                    buffer_len,
+                    block_width,
+                )
+            except RuntimeError:
+                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')
+        raise ValueError("dsp_config must be a dict")
 
     raw_store = lh5.LH5Store()
-    lh5_file = raw_store.gimme_file(f_raw, 'r')
+    lh5_file = raw_store.gimme_file(f_raw, "r")
     if lh5_file is None:
-        raise ValueError(f'input file not found: {f_raw}')
+        raise ValueError(f"input file not found: {f_raw}")
         return
 
     # if no group is specified, assume we want to decode every table in the file
     if lh5_tables is None:
-        lh5_tables = []
-        lh5_keys = lh5.ls(f_raw)
-
-        # sometimes 'raw' is nested, e.g g024/raw
-        for tb in lh5_keys:
-            if "raw" not in tb:
-                tbname = lh5.ls(lh5_file[tb])[0]
-                if "raw" in tbname:
-                    tb = f'{tb}/{tbname}'  # g024 + /raw
-            lh5_tables.append(tb)
-
-    # make sure every group points to waveforms, if not, remove the group
-    for tb in lh5_tables:
-        if 'raw' not in tb:
-            lh5_tables.remove(tb)
+        lh5_tables = lh5.ls(f_raw)
+
+    # check if group points to raw data; sometimes 'raw' is nested, e.g g024/raw
+    for i, tb in enumerate(lh5_tables):
+        if "raw" not in tb and lh5.ls(lh5_file, f"{tb}/raw"):
+            lh5_tables[i] = f"{tb}/raw"
+        elif not lh5.ls(lh5_file, tb):
+            del lh5_tables[i]
+
     if len(lh5_tables) == 0:
         raise RuntimeError(f"could not find any valid LH5 table in {f_raw}")
 
@@ -111,21 +140,26 @@ def build_dsp(f_raw: str, f_dsp: str, dsp_config: str | dict, lh5_tables:
 
     if database and not isinstance(database, dict):
         database = None
-        raise ValueError('input database is not a valid JSON file or dict')
+        raise ValueError("input database is not a valid JSON file or dict")
+
+    if write_mode is None and os.path.isfile(f_dsp):
+        raise FileExistsError(
+            f"output file {f_dsp} exists. Set the 'write_mode' keyword"
+        )
 
     # clear existing output files
-    if write_mode == 'r':
+    if write_mode == "r":
         if os.path.isfile(f_dsp):
             os.remove(f_dsp)
 
     # write processing metadata
     dsp_info = lgdo.Struct()
-    dsp_info.add_field('timestamp', lgdo.Scalar(np.uint64(time.time())))
-    dsp_info.add_field('python_version', lgdo.Scalar(sys.version))
-    dsp_info.add_field('numpy_version', lgdo.Scalar(np.version.version))
-    dsp_info.add_field('h5py_version', lgdo.Scalar(h5py.version.version))
-    dsp_info.add_field('hdf5_version', lgdo.Scalar(h5py.version.hdf5_version))
-    dsp_info.add_field('pygama_version', lgdo.Scalar(pygama.__version__))
+    dsp_info.add_field("timestamp", lgdo.Scalar(np.uint64(time.time())))
+    dsp_info.add_field("python_version", lgdo.Scalar(sys.version))
+    dsp_info.add_field("numpy_version", lgdo.Scalar(np.version.version))
+    dsp_info.add_field("h5py_version", lgdo.Scalar(h5py.version.version))
+    dsp_info.add_field("hdf5_version", lgdo.Scalar(h5py.version.hdf5_version))
+    dsp_info.add_field("pygama_version", lgdo.Scalar(pygama.__version__))
 
     # loop over tables to run DSP on
     for tb in lh5_tables:
@@ -134,23 +168,13 @@ def build_dsp(f_raw: str, f_dsp: str, dsp_config: str | dict, lh5_tables:
         if n_max and n_max < tot_n_rows:
             tot_n_rows = n_max
 
-        # if we have separate DSP files for each table, read them in here
-        if chan_config is not None:
-            f_config = chan_config[tb]
-            with open(f_config) as config_file:
-                dsp_config = json.load(config_file)
-            log.debug(f'processing table: {tb} with DSP config file {f_config}')
-
-        if not isinstance(dsp_config, dict):
-            raise RuntimeError(f'dsp_config for {tb} must be a dict')
-
-        chan_name = tb.split('/')[0]
+        chan_name = tb.split("/")[0]
         db_dict = database.get(chan_name) if database else None
-        tb_name = tb.replace('/raw', '/dsp')
+        tb_name = tb.replace("/raw", "/dsp")
 
         write_offset = 0
-        raw_store.gimme_file(f_dsp, 'a')
-        if write_mode == 'a' and lh5.ls(f_dsp, tb_name):
+        raw_store.gimme_file(f_dsp, "a")
+        if write_mode == "a" and lh5.ls(f_dsp, tb_name):
             write_offset = raw_store.read_n_rows(tb_name, f_dsp)
 
         # Main processing loop
@@ -159,12 +183,19 @@ def build_dsp(f_raw: str, f_dsp: str, dsp_config: str | dict, lh5_tables:
         for lh5_in, start_row, n_rows in lh5_it:
             # Initialize
             if proc_chain is None:
-                proc_chain, lh5_it.field_mask, tb_out = build_processing_chain(lh5_in, dsp_config, db_dict, outputs, block_width)
+                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:
-                    progress_bar = tqdm(desc=f'Processing table {tb}', total=tot_n_rows,
-                                        delay=2, unit='rows', file=sys.stdout)
-
-            n_rows = min(tot_n_rows-start_row, n_rows)
+                    progress_bar = tqdm(
+                        desc=f"Processing table {tb}",
+                        total=tot_n_rows,
+                        delay=2,
+                        unit="rows",
+                        file=sys.stdout,
+                    )
+
+            n_rows = min(tot_n_rows - start_row, n_rows)
             try:
                 proc_chain.execute(0, n_rows)
             except DSPFatal as e:
@@ -172,20 +203,22 @@ def build_dsp(f_raw: str, f_dsp: str, dsp_config: str | dict, lh5_tables:
                 e.wf_range = f"{e.wf_range[0]+start_row}-{e.wf_range[1]+start_row}"
                 raise e
 
-            raw_store.write_object(obj=tb_out,
-                                   name=tb_name,
-                                   lh5_file=f_dsp,
-                                   n_rows=n_rows,
-                                   wo_mode='o' if write_mode == 'u' else 'a',
-                                   write_start=write_offset+start_row)
+            raw_store.write_object(
+                obj=tb_out,
+                name=tb_name,
+                lh5_file=f_dsp,
+                n_rows=n_rows,
+                wo_mode="o" if write_mode == "u" else "a",
+                write_start=write_offset + start_row,
+            )
 
             if log.level <= logging.INFO:
-                progress_bar.update(buffer_len)
+                progress_bar.update(n_rows)
 
-            if start_row+n_rows > tot_n_rows:
+            if start_row + n_rows >= tot_n_rows:
                 break
 
         if log.level <= logging.INFO:
             progress_bar.close()
 
-    raw_store.write_object(dsp_info, 'dsp_info', f_dsp)
+    raw_store.write_object(dsp_info, "dsp_info", f_dsp, wo_mode="o")
diff --git a/src/pygama/dsp/errors.py b/src/pygama/dsp/errors.py
index 457c78287..accbf24d4 100644
--- a/src/pygama/dsp/errors.py
+++ b/src/pygama/dsp/errors.py
@@ -1,5 +1,9 @@
+from __future__ import annotations
+
+
 class DSPError(Exception):
     """Base class for signal processors."""
+
     pass
 
 
@@ -15,20 +19,22 @@ class DSPFatal(DSPError):
         string of processor and arguments. This will be set after the exception
         is caught, and appended to the error message
     """
+
     def __init__(self, *args) -> None:
         super().__init__(*args)
         self.wf_range = None
         self.processor = None
 
     def __str__(self) -> str:
-        suffix = ''
+        suffix = ""
         if self.wf_range:
-            suffix += '\nThrown while processing entries ' + str(self.wf_range)
+            suffix += "\nThrown while processing entries " + str(self.wf_range)
         if self.processor:
-            suffix += '\nThrown by ' + self.processor
+            suffix += "\nThrown by " + self.processor
         return super().__str__() + suffix
 
 
 class ProcessingChainError(DSPError):
     """Error thrown when there is a problem setting up a processing chain."""
+
     pass
diff --git a/src/pygama/dsp/processing_chain.py b/src/pygama/dsp/processing_chain.py
index fcffbc7fe..6333b3131 100644
--- a/src/pygama/dsp/processing_chain.py
+++ b/src/pygama/dsp/processing_chain.py
@@ -13,7 +13,7 @@
 from abc import ABCMeta, abstractmethod
 from copy import deepcopy
 from dataclasses import dataclass
-from typing import Any
+from typing import Any, Union
 
 import numpy as np
 from numba import vectorize
@@ -25,16 +25,20 @@
 
 log = logging.getLogger(__name__)
 
+LGDO = Union[lgdo.Scalar, lgdo.Array, lgdo.VectorOfVectors, lgdo.Struct]
+
 # Filler value for variables to be automatically deduced later
-auto = 'auto'
+auto = "auto"
 
 # Map from ast interpreter operations to functions to call and format string
-ast_ops_dict = {ast.Add: (np.add, '{}+{}'),
-                ast.Sub: (np.subtract, '{}-{}'),
-                ast.Mult: (np.multiply, '{}*{}'),
-                ast.Div: (np.divide, '{}/{}'),
-                ast.FloorDiv: (np.floor_divide, '{}//{}'),
-                ast.USub: (np.negative, '-{}')}
+ast_ops_dict = {
+    ast.Add: (np.add, "{}+{}"),
+    ast.Sub: (np.subtract, "{}-{}"),
+    ast.Mult: (np.multiply, "{}*{}"),
+    ast.Div: (np.divide, "{}/{}"),
+    ast.FloorDiv: (np.floor_divide, "{}//{}"),
+    ast.USub: (np.negative, "-{}"),
+}
 
 
 @dataclass
@@ -47,32 +51,39 @@ class CoordinateGrid:
     case, a :class:`ProcChainVar` variable is used to store a different offset
     for each event.
     """
+
     period: Quantity | Unit | str
     offset: Quantity | ProcChainVar | float | int
 
     def __post_init__(self) -> None:
         if isinstance(self.period, str):
-            self.period = Quantity(1., self.period)
+            self.period = Quantity(1.0, self.period)
         elif isinstance(self.period, Unit):
             self.period *= 1  # make Quantity
         if isinstance(self.offset, (int, float)):
-            self.offset = self.offset*self.period
-        assert isinstance(self.period, Quantity) and isinstance(self.offset, (Quantity, ProcChainVar))
+            self.offset = self.offset * self.period
+        assert isinstance(self.period, Quantity) and isinstance(
+            self.offset, (Quantity, ProcChainVar)
+        )
 
     def __eq__(self, other: CoordinateGrid) -> bool:
         """True if values are equal; if offset is a variable, compares reference"""
-        return self.period == other.period and (self.offset is other.offset if isinstance(self.offset, ProcChainVar) else self.offset == other.offset)
+        return self.period == other.period and (
+            self.offset is other.offset
+            if isinstance(self.offset, ProcChainVar)
+            else self.offset == other.offset
+        )
 
     def unit_str(self) -> str:
-        string = format(self.period.u, '~')
-        if string == '':
+        string = format(self.period.u, "~")
+        if string == "":
             string = str(self.period.u)
         return string
 
     def get_period(self, unit: str | Unit) -> float:
         if isinstance(unit, str):
             unit = ureg.Quantity(unit)
-        return float(self.period/unit)
+        return float(self.period / unit)
 
     def get_offset(self, unit: str | Unit = None) -> float:
         """Get the offset (convert)ed to unit. If `unit` is ``None`` use period."""
@@ -84,11 +95,14 @@ def get_offset(self, unit: str | Unit = None) -> float:
         if isinstance(self.offset, ProcChainVar):
             return self.offset.get_buffer(CoordinateGrid(unit, 0))
         else:
-            return float(self.offset/unit)
+            return float(self.offset / unit)
 
     def __str__(self) -> str:
-        offset = self.offset.name if isinstance(self.offset, ProcChainVar) \
+        offset = (
+            self.offset.name
+            if isinstance(self.offset, ProcChainVar)
             else str(self.offset)
+        )
         return f"({str(self.period)},{offset})"
 
 
@@ -99,25 +113,33 @@ class ProcChainVar:
     Members can be set to ``auto`` to attempt to deduce these when adding this
     variable to a processor for the first time.
     """
-    def __init__(self, proc_chain: ProcessingChain, name: str, shape: tuple = auto,
-                 dtype: np.dtype = auto, grid: CoordinateGrid = auto,
-                 unit: str | Unit = auto, is_coord: bool = auto) -> None:
+
+    def __init__(
+        self,
+        proc_chain: ProcessingChain,
+        name: str,
+        shape: int | tuple[int, ...] = auto,
+        dtype: np.dtype = auto,
+        grid: CoordinateGrid = auto,
+        unit: str | Unit = auto,
+        is_coord: bool = auto,
+    ) -> None:
         """
         Parameters
         ----------
-        proc_chain : ProcessingChain
+        proc_chain
             DOCME
-        name : str
+        name
             DOCME
-        shape : tuple, optional, default='auto'
+        shape
             DOCME
-        dtype : numpy.dtype or str, optional, default='auto'
+        dtype
             DOCME
-        grid : CoordinateGrid
+        grid
             DOCME
-        unit : str or pint.Unit, optional, default='auto'
+        unit
             DOCME
-        is_coord : bool, optional, default='auto'
+        is_coord
             DOCME
         """
         assert isinstance(proc_chain, ProcessingChain) and isinstance(name, str)
@@ -134,32 +156,36 @@ def __init__(self, proc_chain: ProcessingChain, name: str, shape: tuple = auto,
         self.unit = unit
         self.is_coord = is_coord
 
-        log.debug(f'added variable: {self.description()}')
+        log.debug(f"added variable: {self.description()}")
 
     # Use this to enforce type constraints and perform conversions
     def __setattr__(self, name: str, value: Any) -> None:
         if value is auto:
             pass
 
-        elif name == 'shape':
-            if hasattr(value, '__iter__'):
+        elif name == "shape":
+            if hasattr(value, "__iter__"):
                 value = tuple(value)
             else:
                 value = (int(value),)
             value = tuple(value)
             assert all(isinstance(d, int) for d in value)
 
-        elif name == 'dtype' and not isinstance(value, np.dtype):
+        elif name == "dtype" and not isinstance(value, np.dtype):
             value = np.dtype(value)
 
-        elif name == 'grid' and not isinstance(value, CoordinateGrid) and value is not None:
+        elif (
+            name == "grid"
+            and not isinstance(value, CoordinateGrid)
+            and value is not None
+        ):
             period, offset = value if isinstance(value, tuple) else value, 0
             value = CoordinateGrid(period, offset)
 
-        elif name == 'unit' and value is not None:
+        elif name == "unit" and value is not None:
             value = str(value)
 
-        elif name == 'is_coord':
+        elif name == "is_coord":
             value = bool(value)
             if value:
                 if self._buffer is None:
@@ -169,7 +195,7 @@ def __setattr__(self, name: str, value: Any) -> None:
 
         super().__setattr__(name, value)
 
-    def get_buffer(self, unit: str | Unit = None) -> np.array:
+    def get_buffer(self, unit: str | Unit = None) -> np.ndarray:
         # If buffer needs to be created, do so now
         if self._buffer is None:
             if self.shape is auto:
@@ -178,7 +204,9 @@ def get_buffer(self, unit: str | Unit = None) -> np.array:
                 raise ProcessingChainError(f"cannot deduce shape of {self.name}")
 
             # create the buffer so that the array start is aligned in memory on a multiple of 64 bytes
-            self._buffer = np.zeros(shape=(self.proc_chain._block_width,)+self.shape, dtype=self.dtype)
+            self._buffer = np.zeros(
+                shape=(self.proc_chain._block_width,) + self.shape, dtype=self.dtype
+            )
 
         # if variable isn't a coordinate, we're all set
         if self.is_coord is False or self.is_coord is auto:
@@ -187,9 +215,9 @@ def get_buffer(self, unit: str | Unit = None) -> np.array:
         # if no unit is given, use the native unit
         if unit is None:
             if isinstance(self.unit, str):
-                unit = CoordinateGrid(self.unit, 0.)
+                unit = CoordinateGrid(self.unit, 0.0)
         elif not isinstance(unit, CoordinateGrid):
-            unit = CoordinateGrid(unit, 0.)
+            unit = CoordinateGrid(unit, 0.0)
 
         # if this is our first time accessing, no conversion is needed
         if len(self._buffer) == 0:
@@ -198,7 +226,9 @@ def get_buffer(self, unit: str | Unit = None) -> np.array:
             if self.dtype is auto:
                 raise ProcessingChainError(f"cannot deduce shape of {self.name}")
 
-            buff = np.zeros(shape=(self.proc_chain._block_width,)+self.shape, dtype=self.dtype)
+            buff = np.zeros(
+                shape=(self.proc_chain._block_width,) + self.shape, dtype=self.dtype
+            )
             self._buffer.append((buff, unit))
             return buff
 
@@ -225,18 +255,25 @@ def period(self):
     def offset(self):
         return self.grid.offset if self.grid else None
 
-    def description(self):
-        return (f"{self.name}(shape: {self.shape}, "
-                f"dtype: {self.dtype}, grid: {self.grid}, "
-                f"unit: {self.unit}, is_coord: {self.is_coord})")
-
-    def update_auto(self, shape: tuple = auto, dtype: np.dtype = auto,
-                    grid: CoordinateGrid = auto, unit: str | Unit = auto,
-                    is_coord: bool = auto, period: period = None,
-                    offset: offset = 0) -> None:
-        """
-        Update any variables set to auto; leave the others alone. Emit a message
-        only if anything was updated.
+    def description(self) -> str:
+        return (
+            f"{self.name}(shape: {self.shape}, "
+            f"dtype: {self.dtype}, grid: {self.grid}, "
+            f"unit: {self.unit}, is_coord: {self.is_coord})"
+        )
+
+    def update_auto(
+        self,
+        shape: int | tuple[int, ...] = auto,
+        dtype: np.dtype = auto,
+        grid: CoordinateGrid = auto,
+        unit: str | Unit = auto,
+        is_coord: bool = auto,
+        period: period = None,
+        offset: offset = 0,
+    ) -> None:
+        """Update any variables set to ``auto``; leave the others alone. Emit a
+        message only if anything was updated.
         """
         updated = False
 
@@ -262,7 +299,7 @@ def update_auto(self, shape: tuple = auto, dtype: np.dtype = auto,
             self.is_coord = is_coord
             updated = True
         if updated:
-            log.debug(f'updated variable: {self.description()}')
+            log.debug(f"updated variable: {self.description()}")
 
     def __str__(self) -> str:
         return self.name
@@ -299,9 +336,9 @@ def __init__(self, block_width: int = 8, buffer_len: int = None) -> None:
         """
         Parameters
         ----------
-        block_width : int
+        block_width
             number of entries to simultaneously process.
-        buffer_len : int
+        buffer_len
             length of input and output buffers. Should be a multiple of
             `block_width`
         """
@@ -316,9 +353,17 @@ def __init__(self, block_width: int = 8, buffer_len: int = None) -> None:
         self._block_width = block_width
         self._buffer_len = buffer_len
 
-    def add_variable(self, name: str, dtype: np.dtype | str = auto, shape: int | tuple = auto,
-                     grid: CoordinateGrid = auto, unit: str | Unit = auto, is_coord: bool = auto,
-                     period: CoordinateGrid.period = None, offset: CoordinateGrid.offset = 0) -> ProcChainVar:
+    def add_variable(
+        self,
+        name: str,
+        dtype: np.dtype | str = auto,
+        shape: int | tuple[int, ...] = auto,
+        grid: CoordinateGrid = auto,
+        unit: str | Unit = auto,
+        is_coord: bool = auto,
+        period: CoordinateGrid.period = None,
+        offset: CoordinateGrid.offset = 0,
+    ) -> ProcChainVar:
         """Add a named variable containing a block of values or arrays.
 
         Parameters
@@ -346,7 +391,7 @@ def add_variable(self, name: str, dtype: np.dtype | str = auto, shape: int | tup
         """
         self._validate_name(name, raise_exception=True)
         if name in self._vars_dict:
-            raise ProcessingChainError(name+' is already in variable list')
+            raise ProcessingChainError(name + " is already in variable list")
 
         # Construct coordinate grid from period/offset if given
         if grid is auto and period is not None:
@@ -354,29 +399,37 @@ def add_variable(self, name: str, dtype: np.dtype | str = auto, shape: int | tup
                 offset = self.get_variable(offset, expr_only=True)
             grid = CoordinateGrid(period, offset)
 
-        var = ProcChainVar(self, name, shape=shape, dtype=dtype, grid=grid,
-                           unit=unit, is_coord=is_coord)
+        var = ProcChainVar(
+            self,
+            name,
+            shape=shape,
+            dtype=dtype,
+            grid=grid,
+            unit=unit,
+            is_coord=is_coord,
+        )
         self._vars_dict[name] = var
         return var
 
-    # TODO: type: numpy array or lgdo. need an lgdo type
-    def link_input_buffer(self, varname: str, buff=None):
-        """Link an input buffer to a variable
+    def link_input_buffer(
+        self, varname: str, buff: np.ndarray | LGDO = None
+    ) -> np.ndarray | LGDO:
+        """Link an input buffer to a variable.
 
         Parameters
         ----------
-        varname : str
+        varname
             name of internal variable to copy into buffer at the end
             of processor execution. If variable does not yet exist, it will
-            be created with a similar shape to the provided buffer
-        buff : numpy.array or lgdo class, optional, default=None
+            be created with a similar shape to the provided buffer.
+        buff
             object to use as input buffer. If ``None``, create a new buffer
-            with a similar shape to the variable
+            with a similar shape to the variable.
 
         Returns
         -------
-        buffer: numpy.array or lgdo class
-            buff or newly allocated input buffer
+        buffer
+            `buff` or newly allocated input buffer.
         """
         self._validate_name(varname, raise_exception=True)
         var = self.get_variable(varname, expr_only=True)
@@ -384,53 +437,67 @@ def link_input_buffer(self, varname: str, buff=None):
             var = self.add_variable(varname)
 
         if not isinstance(var, ProcChainVar):
-            raise ProcessingChainError("Must link an input buffer to a processing chain variable")
+            raise ProcessingChainError(
+                "Must link an input buffer to a processing chain variable"
+            )
 
         # Create input buffer that will be linked and returned if none exists
         if buff is None:
             if var is None:
-                raise ProcessingChainError(f"{varname} does not exist and no buffer was provided")
+                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)
+                buff = lgdo.WaveformTable(
+                    size=self._buffer_len, wf_len=var.shape[0], dtype=var.dtype
+                )
             elif len(var.shape) == 0:
                 buff = lgdo.Array(shape=(self._buffer_len), dtype=var.dtype)
+            elif len(var.shape) > 0:
+                buff = lgdo.ArrayOfEqualSizedArrays(
+                    shape=(self._buffer_len, *var.shape), dtype=var.dtype
+                )
             else:
                 buff = np.ndarray((self._buffer_len,) + var.shape, var.dtype)
 
         # Add the buffer to the input buffers list
         if isinstance(buff, np.ndarray):
             out_man = NumpyIOManager(buff, var)
+        elif isinstance(buff, lgdo.ArrayOfEqualSizedArrays):
+            out_man = LGDOArrayOfEqualSizedArraysIOManager(buff, var)
         elif isinstance(buff, lgdo.Array):
             out_man = LGDOArrayIOManager(buff, var)
         elif isinstance(buff, lgdo.WaveformTable):
             out_man = LGDOWaveformIOManager(buff, var)
         else:
-            raise ProcessingChainError("Could not link input buffer of unknown type", str(buff))
+            raise ProcessingChainError(
+                "Could not link input buffer of unknown type", str(buff)
+            )
 
         log.debug(f"added input buffer: {out_man}")
         self._input_managers.append(out_man)
 
         return buff
 
-    def link_output_buffer(self, varname: str, buff=None):
+    def link_output_buffer(
+        self, varname: str, buff: np.ndarray | LGDO = None
+    ) -> np.ndarray | LGDO:
         """Link an output buffer to a variable.
 
         Parameters
         ----------
         varname
-            name of internal variable to copy into buffer at the end
-            of processor execution. If variable does not yet exist, it will
-            be created with a similar shape to the provided buffer
-        buff : numpy.array or lgdo class
-            object to use as output buffer. If None, create a new buffer with a
-            similar shape to the variable
+            name of internal variable to copy into buffer at the end of
+            processor execution. If variable does not yet exist, it will be
+            created with a similar shape to the provided buffer.
+        buff
+            object to use as output buffer. If ``None``, create a new buffer
+            with a similar shape to the variable
 
         Returns
         -------
-        numpy.array or lgdo object
-            buff or newly allocated output buffer
+        buffer
+            `buff` or newly allocated output buffer.
         """
         self._validate_name(varname, raise_exception=True)
         var = self.get_variable(varname, expr_only=True)
@@ -438,47 +505,60 @@ def link_output_buffer(self, varname: str, buff=None):
             var = self.add_variable(varname)
 
         if not isinstance(var, ProcChainVar):
-            raise ProcessingChainError("must link an output buffer to a processing chain variable")
+            raise ProcessingChainError(
+                "must link an output buffer to a processing chain variable"
+            )
 
         # Create output buffer that will be linked and returned if none exists
         if buff is None:
             if var is None:
-                raise ProcessingChainError(varname+" does not exist and no buffer was provided")
+                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)
+                buff = lgdo.WaveformTable(
+                    size=self._buffer_len, wf_len=var.shape[0], dtype=var.dtype
+                )
             elif len(var.shape) == 0:
                 buff = lgdo.Array(shape=(self._buffer_len), dtype=var.dtype)
+            elif len(var.shape) > 0:
+                buff = lgdo.ArrayOfEqualSizedArrays(
+                    shape=(self._buffer_len, *var.shape), dtype=var.dtype
+                )
             else:
                 buff = np.ndarray((self._buffer_len,) + var.shape, var.dtype)
 
         # Add the buffer to the output buffers list
         if isinstance(buff, np.ndarray):
             out_man = NumpyIOManager(buff, var)
+        elif isinstance(buff, lgdo.ArrayOfEqualSizedArrays):
+            out_man = LGDOArrayOfEqualSizedArraysIOManager(buff, var)
         elif isinstance(buff, lgdo.Array):
             out_man = LGDOArrayIOManager(buff, var)
         elif isinstance(buff, lgdo.WaveformTable):
             out_man = LGDOWaveformIOManager(buff, var)
         else:
-            raise ProcessingChainError("could not link output buffer of unknown type", str(buff))
+            raise ProcessingChainError(
+                "could not link output buffer of unknown type", str(buff)
+            )
 
         log.debug(f"added output buffer: {out_man}")
         self._output_managers.append(out_man)
 
         return buff
 
-    def add_processor(self, func: np.ufunc, *args, signature: str = None,
-                      types: list[str] = None) -> None:
+    def add_processor(
+        self, func: np.ufunc, *args, signature: str = None, types: list[str] = None
+    ) -> None:
         """Make a list of parameters from `*args`. Replace any strings in the
         list with NumPy objects from `vars_dict`, where able.
         """
         params = []
         kw_params = {}
-        for i, param in enumerate(args):
-            if(isinstance(param, str)):
+        for _, param in enumerate(args):
+            if isinstance(param, str):
                 param = self.get_variable(param)
-            if(isinstance(param, dict)):
+            if isinstance(param, dict):
                 kw_params.update(param)
             else:
                 params.append(param)
@@ -491,10 +571,11 @@ def execute(self, start: int = 0, stop: int = None) -> None:
         if stop is None:
             stop = self._buffer_len
         for i in range(start, stop, self._block_width):
-            self._execute_procs(i, min(i+self._block_width, self._buffer_len))
+            self._execute_procs(i, min(i + self._block_width, self._buffer_len))
 
-    def get_variable(self, expr: str, get_names_only: bool = False,
-                     expr_only: bool = False) -> Any:
+    def get_variable(
+        self, expr: str, get_names_only: bool = False, expr_only: bool = False
+    ) -> Any:
         r"""Parse string `expr` into a NumPy array or value, using the following
         syntax:
 
@@ -524,7 +605,7 @@ def get_variable(self, expr: str, get_names_only: bool = False,
           - ``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
+            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.
@@ -542,14 +623,18 @@ def get_variable(self, expr: str, get_names_only: bool = False,
                 return var
             elif isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
                 if expr_only:
-                    raise ProcessingChainError("kwarg assignment is not allowed in this context\n  " + expr)
+                    raise ProcessingChainError(
+                        "kwarg assignment is not allowed in this context\n  " + expr
+                    )
                 return {stmt.targets[0].id: var}
             else:
                 raise ProcessingChainError("Could not parse expression:\n  " + expr)
         else:
             return names
 
-    def _parse_expr(self, node: Any, expr: str, dry_run: bool, var_name_list: list[str]) -> Any:
+    def _parse_expr(
+        self, node: Any, expr: str, dry_run: bool, var_name_list: list[str]
+    ) -> Any:
         """
         Helper function for :meth:`ProcessingChain.get_variable` that
         recursively evaluates the AST tree. Whenever we encounter a variable
@@ -576,9 +661,10 @@ def _parse_expr(self, node: Any, expr: str, dry_run: bool, var_name_list: list[s
             if node.id in ureg:
                 return ureg(node.id)
 
-            #check if it is a variable
+            # check if it is a variable
             var_name_list.append(node.id)
-            if dry_run: return None
+            if dry_run:
+                return None
 
             val = self._vars_dict.get(node.id, None)
             if val is None:
@@ -589,20 +675,37 @@ def _parse_expr(self, node: Any, expr: str, dry_run: bool, var_name_list: list[s
         elif isinstance(node, ast.BinOp):
             lhs = self._parse_expr(node.left, expr, dry_run, var_name_list)
             rhs = self._parse_expr(node.right, expr, dry_run, var_name_list)
-            if rhs is None or lhs is None: return None
+            if rhs is None or lhs is None:
+                return None
             op, op_form = ast_ops_dict[type(node.op)]
 
             if not (isinstance(lhs, ProcChainVar) or isinstance(rhs, ProcChainVar)):
                 return op(lhs, rhs)
 
-            name = '('+op_form.format(str(lhs), str(rhs))+')'
+            name = "(" + op_form.format(str(lhs), str(rhs)) + ")"
             if isinstance(lhs, ProcChainVar) and isinstance(rhs, ProcChainVar):
-                #TODO: handle units/coords; for now make them match lhs
+                # TODO: handle units/coords; for now make them match lhs
                 out = ProcChainVar(self, name, is_coord=lhs.is_coord)
             elif isinstance(lhs, ProcChainVar):
-                out = ProcChainVar(self, name, lhs.shape, lhs.dtype, lhs.grid, lhs.unit, is_coord=lhs.is_coord)
+                out = ProcChainVar(
+                    self,
+                    name,
+                    lhs.shape,
+                    lhs.dtype,
+                    lhs.grid,
+                    lhs.unit,
+                    is_coord=lhs.is_coord,
+                )
             else:
-                out = ProcChainVar(self, name, rhs.shape, rhs.dtype, rhs.grid, rhs.unit, is_coord=rhs.is_coord)
+                out = ProcChainVar(
+                    self,
+                    name,
+                    rhs.shape,
+                    rhs.dtype,
+                    rhs.grid,
+                    rhs.unit,
+                    is_coord=rhs.is_coord,
+                )
 
             self._proc_managers.append(ProcessorManager(self, op, [lhs, rhs, out]))
             return out
@@ -610,13 +713,21 @@ def _parse_expr(self, node: Any, expr: str, dry_run: bool, var_name_list: list[s
         # define unary operators (-)
         elif isinstance(node, ast.UnaryOp):
             operand = self._parse_expr(node.operand, expr, dry_run, var_name_list)
-            if operand is None: return None
+            if operand is None:
+                return None
             op, op_form = ast_ops_dict[type(node.op)]
-            name = '('+op_form.format(str(operand))+')'
+            name = "(" + op_form.format(str(operand)) + ")"
 
             if isinstance(operand, ProcChainVar):
-                out = ProcChainVar(self, name, operand.shape, operand.dtype,
-                              operand.grid, operand.unit, operand.is_coord)
+                out = ProcChainVar(
+                    self,
+                    name,
+                    operand.shape,
+                    operand.dtype,
+                    operand.grid,
+                    operand.unit,
+                    operand.is_coord,
+                )
                 self._proc_managers.append(ProcessorManager(self, op, [operand, out]))
             else:
                 out = op(out)
@@ -625,7 +736,8 @@ def _parse_expr(self, node: Any, expr: str, dry_run: bool, var_name_list: list[s
 
         elif isinstance(node, ast.Subscript):
             val = self._parse_expr(node.value, expr, dry_run, var_name_list)
-            if val is None: return None
+            if val is None:
+                return None
             if not isinstance(val, ProcChainVar):
                 raise ProcessingChainError("Cannot apply subscript to", node.value)
 
@@ -634,45 +746,53 @@ def get_index(slice_value):
                 if ret is None:
                     return ret
                 if isinstance(ret, Quantity):
-                    ret = float(ret/val.period)
+                    ret = float(ret / val.period)
                 if isinstance(ret, float):
                     round_ret = int(round(ret))
                     if abs(ret - round_ret) > 0.0001:
-                        log.warning(f'slice value {slice_value} is non-integer. Rounding to {round_ret}')
+                        log.warning(
+                            f"slice value {slice_value} is non-integer. Rounding to {round_ret}"
+                        )
                     return round_ret
                 return int(ret)
 
             if isinstance(node.slice, ast.Index):
                 index = get_index(node.slice.value)
                 out_buf = val[..., index]
-                out_name=f'{str(val)}[{index}]',
+                out_name = (f"{str(val)}[{index}]",)
                 out_grid = None
 
             elif isinstance(node.slice, ast.Slice):
-                sl = slice(get_index(node.slice.lower),
-                           get_index(node.slice.upper),
-                           get_index(node.slice.step) )
+                sl = slice(
+                    get_index(node.slice.lower),
+                    get_index(node.slice.upper),
+                    get_index(node.slice.step),
+                )
                 out_buf = val.buffer[..., sl]
-                out_name = '{}[{}:{}{}]'.format(
+                out_name = "{}[{}:{}{}]".format(
                     str(val),
-                    '' if sl.start is None else str(sl.start),
-                    '' if sl.stop is None else str(sl.stop),
-                    '' if sl.step is None else ':'+str(sl.step) )
+                    "" if sl.start is None else str(sl.start),
+                    "" if sl.stop is None else str(sl.stop),
+                    "" if sl.step is None else ":" + str(sl.step),
+                )
 
                 if val.grid is None:
                     out_grid = None
                 else:
                     pd = val.period
-                    if sl.step is not None: pd *= sl.step
+                    if sl.step is not None:
+                        pd *= sl.step
 
                     off = val.offset
                     if sl.start is not None:
-                        start = sl.start*val.period
+                        start = sl.start * val.period
                         if isinstance(off, ProcChainVar):
-                            new_off = ProcChainVar(self,
-                                                   name=f"({str(off)}+{str(start)})",
-                                                   is_coord=True)
-                            self._proc_managers.append(ProcessorManager(self, np.add, [off, start, new_off]))
+                            new_off = ProcChainVar(
+                                self, name=f"({str(off)}+{str(start)})", is_coord=True
+                            )
+                            self._proc_managers.append(
+                                ProcessorManager(self, np.add, [off, start, new_off])
+                            )
                             off = new_off
                         else:
                             off += start
@@ -683,27 +803,35 @@ def get_index(slice_value):
                 raise ProcessingChainError("ExtSlice still isn't implemented...")
 
             # Create our return variable and set the buffer to the slice
-            out = ProcChainVar(self, out_name,
-                               shape = out_buf.shape[1:],
-                               dtype = out_buf.dtype,
-                               grid = out_grid,
-                               unit = val.unit,
-                               is_coord=val.is_coord )
-            out._buffer = [ (out_buf, val._buffer[0][1]) ] if out.is_coord else out_buf
+            out = ProcChainVar(
+                self,
+                out_name,
+                shape=out_buf.shape[1:],
+                dtype=out_buf.dtype,
+                grid=out_grid,
+                unit=val.unit,
+                is_coord=val.is_coord,
+            )
+            out._buffer = [(out_buf, val._buffer[0][1])] if out.is_coord else out_buf
             return out
 
         # for name.attribute
         elif isinstance(node, ast.Attribute):
             val = self._parse_expr(node.value, expr, dry_run, var_name_list)
-            if val is None: return None
+            if val is None:
+                return None
             return getattr(val, node.attr)
 
         # for func(args, kwargs)
         elif isinstance(node, ast.Call):
             func = self.func_list.get(node.func.id, None)
-            args = [ self._parse_expr(arg, expr, dry_run, var_name_list) \
-                     for arg in node.args ]
-            kwargs = { kwarg.arg:self._parse_expr(kwarg.value, expr, dry_run, var_name_list) for kwarg in node.keywords }
+            args = [
+                self._parse_expr(arg, expr, dry_run, var_name_list) for arg in node.args
+            ]
+            kwargs = {
+                kwarg.arg: self._parse_expr(kwarg.value, expr, dry_run, var_name_list)
+                for kwarg in node.keywords
+            }
             if func is not None:
                 return func(*args, **kwargs)
             elif self._validate_name(node.func.id):
@@ -721,13 +849,18 @@ def get_index(slice_value):
             else:
                 raise ProcessingChainError(
                     f"do not recognize call to {func} with arguments "
-                    f"{[str(arg.__dict__) for arg in node.args]}")
+                    f"{[str(arg.__dict__) for arg in node.args]}"
+                )
 
         raise ProcessingChainError(f"cannot parse AST nodes of type {node.__dict__}")
 
     def _validate_name(self, name: str, raise_exception: bool = False) -> bool:
         """Check that name is alphanumeric, and not an already used keyword"""
-        isgood = re.match(r"\A\w+$", name) and name not in self.func_list and name not in ureg
+        isgood = (
+            re.match(r"\A\w+$", name)
+            and name not in self.func_list
+            and name not in ureg
+        )
         if raise_exception and not isgood:
             raise ProcessingChainError(f"{name} is not a valid variable name")
         return isgood
@@ -753,17 +886,19 @@ def _execute_procs(self, begin: int, end: int) -> str:
         for out_man in self._output_managers:
             out_man.write(begin, end)
 
-    def __str__(self):
-        return 'Input variables:\n  ' \
-             + '\n  '.join([str(in_man) for in_man in self._input_managers]) \
-             + '\nProcessors:\n  ' \
-             + '\n  '.join([str(proc_man) for proc_man in self._proc_managers]) \
-             + '\nOutput variables:\n  ' \
-             + '\n  '.join([str(out_man) for out_man in self._output_managers])
+    def __str__(self) -> str:
+        return (
+            "Input variables:\n  "
+            + "\n  ".join([str(in_man) for in_man in self._input_managers])
+            + "\nProcessors:\n  "
+            + "\n  ".join([str(proc_man) for proc_man in self._proc_managers])
+            + "\nOutput variables:\n  "
+            + "\n  ".join([str(out_man) for out_man in self._output_managers])
+        )
 
     # Define functions that can be parsed by get_variable
     # Get length of ProcChainVar
-    def _length(var: ProcChainVar) -> int:
+    def _length(var: ProcChainVar) -> int:  # noqa: N805
         if var is None:
             return None
         if not isinstance(var, ProcChainVar):
@@ -773,16 +908,18 @@ def _length(var: ProcChainVar) -> int:
         return var.buffer.shape[1]
 
     # round value
-    def _round(var: ProcChainVar) -> float:
+    def _round(var: ProcChainVar) -> float:  # noqa: N805
         if var is None:
             return None
         if not isinstance(var, ProcChainVar):
             return round(float(var))
         else:
-            raise ProcessingChainError("round() is not implemented for variables, only constants.")
+            raise ProcessingChainError(
+                "round() is not implemented for variables, only constants."
+            )
 
     # dict of functions that can be parsed by get_variable
-    func_list = {'len': _length, 'round': _round}
+    func_list = {"len": _length, "round": _round}
 
 
 class ProcessorManager:
@@ -793,11 +930,24 @@ class DimInfo:
         length: int  # length of arrays in this dimension
         grid: CoordinateGrid  # period and offset of arrays in this dimension
 
-    def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str],
-                 kw_params: dict = {}, signature: str = None, types: list[str] = None) -> None:
-
-        assert isinstance(proc_chain, ProcessingChain) and callable(func) \
+    def __init__(
+        self,
+        proc_chain: ProcessingChain,
+        func: np.ufunc,
+        params: list[str],
+        kw_params: dict = None,
+        signature: str = None,
+        types: list[str] = None,
+    ) -> None:
+
+        assert (
+            isinstance(proc_chain, ProcessingChain)
+            and callable(func)
             and isinstance(params, list)
+        )
+
+        if kw_params is None:
+            kw_params = {}
 
         # reference back to our processing chain
         self.proc_chain = proc_chain
@@ -815,19 +965,22 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
         # Get the signature and list of valid types for the function
         self.signature = func.signature if signature is None else signature
         if self.signature is None:
-            self.signature = ','.join(['()']*func.nin) + '->' \
-                           + ','.join(['()']*func.nout)
+            self.signature = (
+                ",".join(["()"] * func.nin) + "->" + ",".join(["()"] * func.nout)
+            )
 
         # Get list of allowed type signatures
         if types is None:
             types = func.types.copy()
         if types is None:
-            raise ProcessingChainError(f"""could not find a type signature list
+            raise ProcessingChainError(
+                f"""could not find a type signature list
                                            for {func.__name__}. Please supply a
-                                           valid list of types.""")
+                                           valid list of types."""
+            )
         if not isinstance(types, list):
             types = [types]
-        found_types = [typestr.replace('->', '') for typestr in types]
+        found_types = [typestr.replace("->", "") for typestr in types]
 
         # Make sure arrays obey the broadcasting rules, and make a dictionary
         # of the correct dimensions and unit system
@@ -837,26 +990,34 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
             raise ProcessingChainError(
                 f"expected {len(dims_list)} arguments from signature "
                 f"{self.signature}; found "
-                f"{len(params)}: ({', '.join([str(par) for par in params])})")
+                f"{len(params)}: ({', '.join([str(par) for par in params])})"
+            )
 
         dims_dict = {}  # map from dim name -> DimInfo
         outerdims = []  # list of DimInfo
         grid = None  # period/offset to use for unit and coordinate conversions
 
-        for ipar, (dims, param) in enumerate(zip(dims_list, it.chain(self.params, self.kw_params.values()))):
+        for ipar, (dims, param) in enumerate(
+            zip(dims_list, it.chain(self.params, self.kw_params.values()))
+        ):
             if not isinstance(param, ProcChainVar):
                 continue
 
             # find type signatures that match type of array
             if param.dtype is not auto:
                 arr_type = param.dtype.char
-                found_types = [type_sig for type_sig in found_types if np.can_cast(arr_type, type_sig[ipar])]
+                found_types = [
+                    type_sig
+                    for type_sig in found_types
+                    if np.can_cast(arr_type, type_sig[ipar])
+                ]
 
             # fill out dimensions from dim signature and check if it works
             if param.shape is auto:
                 continue
-            fun_dims = [od for od in outerdims] + \
-                [d.strip() for d in dims.split(',') if d]
+            fun_dims = [od for od in outerdims] + [
+                d.strip() for d in dims.split(",") if d
+            ]
             arr_dims = list(param.shape)
             arr_grid = param.grid if param.grid is not auto else None
             if not grid:
@@ -864,8 +1025,8 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
 
             # check if arr_dims can be broadcast to match fun_dims
             for i in range(max(len(fun_dims), len(arr_dims))):
-                fd = fun_dims[-i-1] if i < len(fun_dims) else None
-                ad = arr_dims[-i-1] if i < len(arr_dims) else None
+                fd = fun_dims[-i - 1] if i < len(fun_dims) else None
+                ad = arr_dims[-i - 1] if i < len(arr_dims) else None
 
                 if isinstance(fd, str):
                     if fd in dims_dict:
@@ -874,14 +1035,16 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
                             raise ProcessingChainError(
                                 f"failed to broadcast array dimensions for "
                                 f"{func.__name}. Could not find consistent value "
-                                f"for dimension {fd}")
+                                f"for dimension {fd}"
+                            )
                         if not this_dim.grid:
                             dims_dict[fd].grid = arr_grid
                         elif arr_grid and arr_grid != this_dim.grid:
                             log.debug(
                                 f"arrays of dimension {fd} for "
                                 f"{func.__name__} do not have consistent period "
-                                f"and offset!")
+                                f"and offset!"
+                            )
                     else:
                         dims_dict[fd] = self.DimInfo(ad, arr_grid)
 
@@ -895,23 +1058,26 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
                 elif fd.length != ad:
                     # If dimensions disagree, either insert a broadcasted array dimension or raise an exception
                     if len(fun_dims) > len(arr_dims):
-                        arr_dims.insert(len(arr_dims)-i, 1)
+                        arr_dims.insert(len(arr_dims) - i, 1)
                     elif len(fun_dims) < len(arr_dims):
-                        outerdims.insert(len(fun_dims)-i, self.DimInfo(ad, arr_grid))
-                        fun_dims.insert(len(fun_dims)-i, ad)
+                        outerdims.insert(len(fun_dims) - i, self.DimInfo(ad, arr_grid))
+                        fun_dims.insert(len(fun_dims) - i, ad)
                     else:
                         raise ProcessingChainError(
                             f"failed to broadcast array dimensions for "
                             f"{func.__name__}. Input arrays do not have "
                             f"consistent outer dimensions.  Require: "
                             f"{tuple(dim.length for dim in outerdims+fun_dims)}; "
-                            f"found {tuple(arr_dims)} for {param}")
+                            f"found {tuple(arr_dims)} for {param}"
+                        )
                 elif not fd.grid:
-                    outerdims[len(fun_dims)-i].grid = arr_grid
+                    outerdims[len(fun_dims) - i].grid = arr_grid
 
                 elif arr_grid and fd.grid != arr_grid:
-                    log.debug(f"arrays of dimension {fd} for {func.__name__} "
-                              f"do not have consistent period and offset!")
+                    log.debug(
+                        f"arrays of dimension {fd} for {func.__name__} "
+                        f"do not have consistent period and offset!"
+                    )
 
                 arr_grid = None  # this is only used for inner most dim
 
@@ -922,7 +1088,8 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
                     continue
             raise ProcessingChainError(
                 f"could not find a type signature matching the types of the "
-                f"variables given for {self} (types: {types})")
+                f"variables given for {self} (types: {types})"
+            )
         # Use the first types in the list that all our types can be cast to
         self.types = [np.dtype(t) for t in found_types[0]]
 
@@ -931,13 +1098,21 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
         # Reshape variable arrays to add broadcast dimensions
         # Allocate new arrays as needed
         # Convert coords to right system of units as needed
-        for i, ((arg_name, param), dims, dtype) in enumerate(zip(it.chain(zip(it.repeat(None), self.params), self.kw_params.items()), dims_list, self.types)):
+        for _, ((arg_name, param), dims, dtype) in enumerate(
+            zip(
+                it.chain(zip(it.repeat(None), self.params), self.kw_params.items()),
+                dims_list,
+                self.types,
+            )
+        ):
             dim_list = outerdims.copy()
-            for d in dims.split(','):
+            for d in dims.split(","):
                 if not d:
                     continue
                 if d not in dims_dict:
-                    raise ProcessingChainError(f"could not deduce dimension {d} for {param}")
+                    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
@@ -946,14 +1121,23 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
                 # Deduce any automated descriptions of parameter
                 unit = None
                 is_coord = False
-                if param.is_coord == True and grid is not None:
+                if param.is_coord is True and grid is not None:
                     unit = str(grid.period.u)
-                elif isinstance(param.unit, str) and param.unit in ureg and grid is not None and ureg.is_compatible_with(grid.period, param.unit):
+                elif (
+                    isinstance(param.unit, str)
+                    and param.unit in ureg
+                    and grid is not None
+                    and ureg.is_compatible_with(grid.period, param.unit)
+                ):
                     is_coord = True
 
-                param.update_auto(shape=shape, dtype=np.dtype(dtype),
-                                  grid=this_grid, unit=unit,
-                                  is_coord=is_coord)
+                param.update_auto(
+                    shape=shape,
+                    dtype=np.dtype(dtype),
+                    grid=this_grid,
+                    unit=unit,
+                    is_coord=is_coord,
+                )
 
                 if param.is_coord and not grid:
                     grid = param._buffer[0][1]
@@ -961,9 +1145,9 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
 
                 # reshape just in case there are some missing dimensions
                 arshape = list(param.shape)
-                for idim in range(-1, -1-len(shape), -1):
+                for idim in range(-1, -1 - len(shape), -1):
                     if arshape[idim] != shape[idim]:
-                        arshape.insert(len(arshape)+idim+1, 1)
+                        arshape.insert(len(arshape) + idim + 1, 1)
                 param = param.reshape(tuple(arshape))
 
             elif param is not None:
@@ -971,12 +1155,15 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
                 if isinstance(param, (Quantity, Unit)):
                     if ureg.is_compatible_with(ureg.dimensionless, param):
                         param = float(param)
-                    elif not isinstance(grid, CoordinateGrid) or not ureg.is_compatible_with(grid.period, param):
+                    elif not isinstance(
+                        grid, CoordinateGrid
+                    ) or not ureg.is_compatible_with(grid.period, param):
                         raise ProcessingChainError(
                             f"could not find valid conversion for {param}; "
-                            f"CoordinateGrid is {grid}")
+                            f"CoordinateGrid is {grid}"
+                        )
                     else:
-                        param = float(param/grid.period)
+                        param = float(param / grid.period)
                 if np.issubdtype(dtype, np.integer):
                     param = dtype.type(round(param))
                 else:
@@ -987,21 +1174,28 @@ def __init__(self, proc_chain: ProcessingChain, func: np.ufunc, params: list[str
             else:
                 self.kwargs[arg_name] = param
 
-        log.debug(f'added processor: {self}')
+        log.debug(f"added processor: {self}")
 
     def execute(self) -> None:
         self.processor(*self.args, **self.kwargs)
 
     def __str__(self) -> str:
-        return self.processor.__name__ + '(' \
-            + ", ".join([str(par) for par in self.params] + [f"{k}={str(v)}" for k, v in self.kw_params.items()]) + ')'
+        return (
+            self.processor.__name__
+            + "("
+            + ", ".join(
+                [str(par) for par in self.params]
+                + [f"{k}={str(v)}" for k, v in self.kw_params.items()]
+            )
+            + ")"
+        )
 
 
 class UnitConversionManager(ProcessorManager):
     """A special processor manager for handling converting variables between unit systems."""
 
     @vectorize(nopython=True, cache=True)
-    def convert(buf_in, offset_in, offset_out, period_ratio):
+    def convert(buf_in, offset_in, offset_out, period_ratio):  # noqa: N805
         return (buf_in - offset_in) * period_ratio + offset_out
 
     def __init__(self, var: ProcChainVar, unit: str | Unit) -> None:
@@ -1016,11 +1210,16 @@ 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.args = [from_buffer, from_grid.get_offset(),
-                     unit.get_offset(), period_ratio, self.out_buffer]
+        self.args = [
+            from_buffer,
+            from_grid.get_offset(),
+            unit.get_offset(),
+            period_ratio,
+            self.out_buffer,
+        ]
         self.kwargs = {}
 
-        log.debug(f'added conversion: {self}')
+        log.debug(f"added conversion: {self}")
 
 
 class IOManager(metaclass=ABCMeta):
@@ -1030,6 +1229,7 @@ class IOManager(metaclass=ABCMeta):
     should update variable with any information from buffer, and check that
     buffer and variable are compatible.
     """
+
     @abstractmethod
     def read(self, start: int, end: int) -> None:
         pass
@@ -1045,19 +1245,17 @@ def __str__(self) -> str:
 
 # Ok, this one's not LGDO
 class NumpyIOManager(IOManager):
-    def __init__(self, io_buf: np.array, var: ProcChainVar):
-        assert isinstance(io_buf, np.ndarray) \
-            and isinstance(var, ProcChainVar)
+    """IO Manager for buffers that are numpy arrays"""
 
-        var.update_auto(dtype=io_buf.dtype,
-                        shape=io_buf.shape[1:])
+    def __init__(self, io_buf: np.ndarray, var: ProcChainVar) -> None:
+        assert isinstance(io_buf, np.ndarray) and isinstance(var, ProcChainVar)
+
+        var.update_auto(dtype=io_buf.dtype, shape=io_buf.shape[1:])
 
         if var.shape != io_buf.shape[1:] or var.dtype != io_buf.dtype:
             raise ProcessingChainError(
-                r"numpy.array<{}>(\{{}\}@{}) is not compatible with variable {}".format(
-                    self.io_buf.shape, self.io_buf.dtype, self.io_buf.data,
-                    str(self.var)
-                )
+                f"numpy.array<{self.io_buf.shape}>({{{self.io_buf.dtype}}}@{self.io_buf.data}) "
+                "is not compatible with variable {self.var}"
             )
 
         self.io_buf = io_buf
@@ -1065,33 +1263,88 @@ def __init__(self, io_buf: np.array, var: ProcChainVar):
         self.raw_var = var.buffer
 
     def read(self, start: int, end: int) -> None:
-        np.copyto(self.raw_var[0:end-start, ...],
-                  self.io_buf[start:end, ...], 'unsafe')
+        np.copyto(
+            self.raw_var[0 : end - start, ...], self.io_buf[start:end, ...], "unsafe"
+        )
 
     def write(self, start: int, end: int) -> None:
-        np.copyto(self.io_buf[start:end, ...],
-                  self.raw_var[0:end-start, ...], 'unsafe')
+        np.copyto(
+            self.io_buf[start:end, ...], self.raw_var[0 : end - start, ...], "unsafe"
+        )
 
     def __str__(self) -> str:
-        return (f"{self.var} linked to numpy.array({self.io_buf.shape}, "
-                f"{self.io_buf.dtype})@{self.io_buf.data})")
+        return (
+            f"{self.var} linked to numpy.array(shape={self.io_buf.shape}, "
+            f"dtype={self.io_buf.dtype})"
+        )
 
 
 class LGDOArrayIOManager(IOManager):
-    def __init__(self, io_array: np.array, var: ProcChainVar) -> None:
-        assert isinstance(io_array, lgdo.Array) \
-            and isinstance(var, ProcChainVar)
+    """IO Manager for buffers that are lgdo Arrays"""
+
+    def __init__(self, io_array: lgdo.Array, var: ProcChainVar) -> None:
+        assert isinstance(io_array, lgdo.Array) and isinstance(var, ProcChainVar)
+
+        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
+            elif ureg.is_compatible_with(var.unit.period, unit):
+                unit = ureg.Quantity(unit).u
+            else:
+                raise ProcessingChainError(
+                    f"LGDO array and variable {var} have incompatible units "
+                    f"({var.unit.period.u} and {unit})"
+                )
+
+        if unit is None and var.unit is not None:
+            io_array.attrs["units"] = str(var.unit)
 
-        unit = io_array.attrs.get('units', None)
-        var.update_auto(dtype=io_array.dtype,
-                        shape=io_array.nda.shape[1:],
-                        unit=unit)
+        self.io_array = io_array
+        self.raw_buf = io_array.nda
+        self.var = var
+        self.raw_var = var.get_buffer(unit)
+
+    def read(self, start: int, end: int) -> None:
+        np.copyto(
+            self.raw_var[0 : end - start, ...], self.raw_buf[start:end, ...], "unsafe"
+        )
+
+    def write(self, start: int, end: int) -> None:
+        np.copyto(
+            self.raw_buf[start:end, ...], self.raw_var[0 : end - start, ...], "unsafe"
+        )
+
+    def __str__(self) -> str:
+        return f"{self.var} linked to lgdo.Array(shape={self.io_array.nda.shape}, dtype={self.io_array.nda.dtype}, attrs={self.io_array.attrs})"
+
+
+class LGDOArrayOfEqualSizedArraysIOManager(IOManager):
+    """IO Manager for buffers that are numpy ArrayOfEqualSizedArrays"""
+
+    def __init__(self, io_array: np.ArrayOfEqualSizedArrays, var: ProcChainVar) -> None:
+        assert isinstance(io_array, lgdo.ArrayOfEqualSizedArrays) and isinstance(
+            var, ProcChainVar
+        )
+
+        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)}")
+                f"incompatible with {str(self.var)}"
+            )
 
         if isinstance(var.unit, CoordinateGrid):
             if unit is None:
@@ -1101,10 +1354,11 @@ def __init__(self, io_array: np.array, var: ProcChainVar) -> None:
             else:
                 raise ProcessingChainError(
                     f"LGDO array and variable {var} have incompatible units "
-                    f"({var.unit.period.u} and {unit})")
+                    f"({var.unit.period.u} and {unit})"
+                )
 
         if unit is None and var.unit is not None:
-            io_array.attrs['units'] = str(var.unit)
+            io_array.attrs["units"] = str(var.unit)
 
         self.io_array = io_array
         self.raw_buf = io_array.nda
@@ -1112,21 +1366,24 @@ def __init__(self, io_array: np.array, var: ProcChainVar) -> None:
         self.raw_var = var.get_buffer(unit)
 
     def read(self, start: int, end: int) -> None:
-        np.copyto(self.raw_var[0:end-start, ...],
-                  self.raw_buf[start:end, ...], 'unsafe')
+        np.copyto(
+            self.raw_var[0 : end - start, ...], self.raw_buf[start:end, ...], "unsafe"
+        )
 
     def write(self, start: int, end: int) -> None:
-        np.copyto(self.raw_buf[start:end, ...],
-                  self.raw_var[0:end-start, ...], 'unsafe')
+        np.copyto(
+            self.raw_buf[start:end, ...], self.raw_var[0 : end - start, ...], "unsafe"
+        )
 
     def __str__(self) -> str:
-        return f'{self.var} linked to {self.io_array}'
+        return f"{self.var} linked to lgdo.ArrayOfEqualSizedArrays(shape={self.io_array.nda.shape}, dtype={self.io_array.nda.dtype}, attrs={self.io_array.attrs})"
 
 
 class LGDOWaveformIOManager(IOManager):
-    def __init__(self, wf_table: lgdo.WaveformTable, variable: ProcChainVar):
-        assert isinstance(wf_table, lgdo.WaveformTable) \
-            and isinstance(variable, ProcChainVar)
+    def __init__(self, wf_table: lgdo.WaveformTable, variable: ProcChainVar) -> None:
+        assert isinstance(wf_table, lgdo.WaveformTable) and isinstance(
+            variable, ProcChainVar
+        )
 
         self.wf_table = wf_table
         self.wf_buf = wf_table.values.nda
@@ -1141,26 +1398,36 @@ def __init__(self, wf_table: lgdo.WaveformTable, variable: ProcChainVar):
             t0_units = dt_units
 
         # If needed create a new coordinate grid from the IO buffer
-        if variable.grid is auto and \
-           isinstance(dt_units, str) and dt_units in ureg and \
-           isinstance(t0_units, str) and t0_units in ureg:
-            grid = CoordinateGrid(ureg.Quantity(self.dt_buf[0], dt_units),
-                                  ProcChainVar(variable.proc_chain,
-                                               variable.name + "_dt",
-                                               shape=(),
-                                               dtype=self.t0_buf.dtype,
-                                               grid=None,
-                                               unit=dt_units,
-                                               is_coord=True))
+        if (
+            variable.grid is auto
+            and isinstance(dt_units, str)
+            and dt_units in ureg
+            and isinstance(t0_units, str)
+            and t0_units in ureg
+        ):
+            grid = CoordinateGrid(
+                ureg.Quantity(self.dt_buf[0], dt_units),
+                ProcChainVar(
+                    variable.proc_chain,
+                    variable.name + "_dt",
+                    shape=(),
+                    dtype=self.t0_buf.dtype,
+                    grid=None,
+                    unit=dt_units,
+                    is_coord=True,
+                ),
+            )
         else:
             grid = None
 
         self.var = variable
-        self.var.update_auto(shape=self.wf_buf.shape[1:],
-                             dtype=self.wf_buf.dtype,
-                             grid=grid,
-                             unit=wf_table.values_units,
-                             is_coord=False)
+        self.var.update_auto(
+            shape=self.wf_buf.shape[1:],
+            dtype=self.wf_buf.dtype,
+            grid=grid,
+            unit=wf_table.values_units,
+            is_coord=False,
+        )
 
         if dt_units is None:
             dt_units = self.var.grid.unit_str()
@@ -1178,34 +1445,42 @@ def __init__(self, wf_table: lgdo.WaveformTable, variable: ProcChainVar):
         self.wf_table.dt_units = dt_units
 
     def read(self, start: int, end: int) -> None:
-        self.wf_var[0:end-start, ...] = self.wf_buf[start:end, ...]
-        self.t0_var[0:end-start, ...] = self.t0_buf[start:end, ...]
+        self.wf_var[0 : end - start, ...] = self.wf_buf[start:end, ...]
+        self.t0_var[0 : end - start, ...] = self.t0_buf[start:end, ...]
 
     def write(self, start: int, end: int) -> None:
-        self.wf_buf[start:end, ...] = self.wf_var[0:end-start, ...]
+        self.wf_buf[start:end, ...] = self.wf_var[0 : end - start, ...]
         if self.variable_t0:
-            self.t0_buf[start:end, ...] = self.t0_var[0:end-start, ...]
+            self.t0_buf[start:end, ...] = self.t0_var[0 : end - start, ...]
 
     def __str__(self) -> str:
-        return (f"{self.var} linked to <pygama.lgdo.WaveformTable: values: "
-                f"{self.wf_table.values}, dt: {self.wf_table.dt}, t0: "
-                f"{self.wf_table.t0}>")
-
-
-def build_processing_chain(lh5_in: lgdo.Table, dsp_config: dict | str, db_dict: dict = None,
-                           outputs: list[str] = None, block_width: int = 16) -> tuple[ProcessingChain, list[str], lgdo.Table]:
+        return (
+            f"{self.var} linked to pygama.lgdo.WaveformTable("
+            f"values(shape={self.wf_table.values.nda.shape}, dtype={self.wf_table.values.nda.dtype}, attrs={self.wf_table.values.attrs}), "
+            f"dt(shape={self.wf_table.dt.nda.shape}, dtype={self.wf_table.dt.nda.dtype}, attrs={self.wf_table.dt.attrs}), "
+            f"t0(shape={self.wf_table.t0.nda.shape}, dtype={self.wf_table.t0.nda.dtype}, attrs={self.wf_table.t0.attrs}))"
+        )
+
+
+def build_processing_chain(
+    lh5_in: lgdo.Table,
+    dsp_config: dict | str,
+    db_dict: dict = None,
+    outputs: list[str] = None,
+    block_width: int = 16,
+) -> tuple[ProcessingChain, list[str], lgdo.Table]:
     """Produces a :class:`ProcessingChain` object and an LH5
     :class:`~.lgdo.table.Table` for output parameters from an input LH5
     :class:`~.lgdo.table.Table` and a JSON recipe.
 
     Parameters
     ----------
-    lh5_in : lgdo.Table
+    lh5_in
         HDF5 table from which raw data is read. At least one row of entries
         should be read in prior to calling this!
 
-    dsp_config : dict or str
-        A dict or json filename containing the recipes for computing DSP
+    dsp_config
+        A dictionary or JSON filename containing the recipes for computing DSP
         parameter from raw parameters. The format is as follows:
 
         .. code-block:: json
@@ -1233,42 +1508,43 @@ def build_processing_chain(lh5_in: lgdo.Table, dsp_config: dict | str, db_dict:
              names of parameters computed
 
               - ``function`` -- string, name of function to call.  Function
-                should implement the gufunc interface, a factory function
-                returning a gufunc, or an arbitrary function that can be
-                mapped onto a gufunc
+                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.
+                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 gufunc via a factory function
+                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 : dict, optional, default=None
-        A nested dict pointing to values for db args. e.g. if a processor
-        uses arg db.trap.risetime, it will look up db_dict['trap']['risetime']
-        and use the found value. If no value is found, use the default
-        defined in the config file.
+    db_dict
+        A nested :class:`dict` pointing to values for database arguments. As
+        instance, if a processor uses the argument ``db.trap.risetime``, it
+        will look up ``db_dict['trap']['risetime']`` and use the found value.
+        If no value is found, use the default defined in `dsp_config`.
 
-    outputs : list of str, optional, default=None
-        List of parameters to put in the output lh5 table. If None,
-        use the parameters in the 'outputs' list from config
+    outputs
+        List of parameters to put in the output LH5 table. If ``None``,
+        use the parameters in the ``"outputs"`` list from `dsp_config`.
 
-    block_width : int, optional, default=16
+    block_width
         number of entries to process at once. To optimize performance,
         a multiple of 16 is preferred, but if performance is not an issue
         any value can be used.
 
     Returns
     -------
-    (proc_chain, field_mask, lh5_out) : tuple
+    (proc_chain, field_mask, lh5_out)
         - `proc_chain` -- :class:`ProcessingChain` object that is executed
         - `field_mask` -- list of input fields that are used
         - `lh5_out` -- output :class:`~.lgdo.table.Table` containing processed
@@ -1280,45 +1556,48 @@ def build_processing_chain(lh5_in: lgdo.Table, dsp_config: dict | str, db_dict:
         with open(dsp_config) as f:
             dsp_config = json.load(f)
     elif dsp_config is None:
-        dsp_config = {'outputs': [], 'processors': {}}
+        dsp_config = {"outputs": [], "processors": {}}
     else:
         # We don't want to modify the input!
         dsp_config = deepcopy(dsp_config)
 
     if outputs is None:
-        outputs = dsp_config['outputs']
+        outputs = dsp_config["outputs"]
 
-    processors = dsp_config['processors']
+    processors = dsp_config["processors"]
 
     # prepare the processor list
     multi_out_procs = {}
     db_parser = re.compile(r"db.[\w_.]+")
     for key, node in processors.items():
         # if we have multiple outputs, add each to the processesors list
-        keys = [k for k in re.split(",| ", key) if k != '']
+        keys = [k for k in re.split(",| ", key) if k != ""]
         if len(keys) > 1:
             for k in keys:
                 multi_out_procs[k] = key
 
         # find DB lookups in args and replace the values
-        args = node['args']
+        args = node["args"]
         for i, arg in enumerate(args):
             if not isinstance(arg, str):
                 continue
             for db_var in db_parser.findall(arg):
                 try:
                     db_node = db_dict
-                    for key in db_var[3:].split('.'):
+                    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):
                     try:
-                        db_node = node['defaults'][db_var]
-                        log.debug(f"Database lookup: using default value of {db_node} for {db_var}")
+                        db_node = node["defaults"][db_var]
+                        log.debug(
+                            f"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 could
-                                not find default value.""")
+                                not find default value."""
+                        )
                 if arg == db_var:
                     arg = db_node
                 else:
@@ -1326,22 +1605,23 @@ def build_processing_chain(lh5_in: lgdo.Table, dsp_config: dict | str, db_dict:
             args[i] = arg
 
         # parse the arguments list for prereqs, if not included explicitly
-        if 'prereqs' not in node:
+        if "prereqs" not in node:
             prereqs = []
-            for arg in node['args']:
+            for arg in node["args"]:
                 if not isinstance(arg, str):
                     continue
                 for prereq in proc_chain.get_variable(arg, True):
                     if prereq not in prereqs and prereq not in keys:
                         prereqs.append(prereq)
-            node['prereqs'] = prereqs
+            node["prereqs"] = prereqs
 
         log.debug(f"prereqs for {key} are {node['prereqs']}")
 
     processors.update(multi_out_procs)
 
-    def resolve_dependencies(par: str, resolved: list[str],
-                             leafs: list[str], unresolved: list[str] = []) -> None:
+    def resolve_dependencies(
+        par: str, resolved: list[str], leafs: list[str], unresolved: list[str] = None
+    ) -> None:
         """
         Recursive function to crawl through the parameters/processors and get a
         sequence of unique parameters such that parameters always appear after
@@ -1351,10 +1631,15 @@ def resolve_dependencies(par: str, resolved: list[str],
 
         .. [ref] https://www.electricmonk.nl/docs/dependency_resolving_algorithm/dependency_resolving_algorithm.html
         """
+        if unresolved is None:
+            unresolved = []
+
         if par in resolved:
             return
         elif par in unresolved:
-            raise ProcessingChainError(f'Circular references detected: {par} -> {edge}')
+            raise ProcessingChainError(
+                f"Circular references detected for parameter '{par}'"
+            )
 
         # if we don't find a node, this is a leaf
         node = processors.get(par)
@@ -1368,7 +1653,7 @@ def resolve_dependencies(par: str, resolved: list[str],
             resolve_dependencies(node, resolved, leafs, unresolved)
             return
 
-        edges = node['prereqs']
+        edges = node["prereqs"]
         unresolved.append(par)
         for edge in edges:
             resolve_dependencies(edge, resolved, leafs, unresolved)
@@ -1386,59 +1671,64 @@ def resolve_dependencies(par: str, resolved: list[str],
             resolve_dependencies(out_par, proc_par_list, input_par_list)
             out_par_list.append(out_par)
 
-    log.debug(f'processing parameters: {proc_par_list}')
-    log.debug(f'required input parameters: {input_par_list}')
-    log.debug(f'copied output parameters: {copy_par_list}')
-    log.debug(f'processed output parameters: {out_par_list}')
+    log.debug(f"processing parameters: {proc_par_list}")
+    log.debug(f"required input parameters: {input_par_list}")
+    log.debug(f"copied output parameters: {copy_par_list}")
+    log.debug(f"processed output parameters: {out_par_list}")
 
     # Now add all of the input buffers from lh5_in (and also the clk time)
     for input_par in input_par_list:
         buf_in = lh5_in.get(input_par)
         if buf_in is None:
-            log.warning(f"I don't know what to do with {input_par}. Building output without it!")
+            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)
 
     # 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']
+        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 != '']
+        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)
+                unit = recipe.get("unit", auto)
                 if isinstance(unit, list):
                     unit = unit[i]
 
                 proc_chain.add_variable(name, unit=unit)
 
         # get this list of kwargs
-        kwargs = recipe.get('kwargs', {})  # might also need db lookup here
+        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_in = recipe["init_args"]
             init_args = []
             init_kwargs = {}
-            for i, arg in enumerate(init_args_in):
+            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('.'):
+                        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):
                         try:
-                            db_node = recipe['defaults'][db_var]
-                            log.debug("database lookup: using default value of {db_node} for {db_var}")
+                            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.")
+                                f"could not find default value."
+                            )
 
                     if arg == db_var:
                         arg = db_node
@@ -1453,7 +1743,10 @@ def resolve_dependencies(par: str, resolved: list[str],
                 else:
                     init_args.append(arg)
 
-            expr = ", ".join([f"{a}" for a in init_args] + [f"{k}={v}" for k, v in init_kwargs.items()])
+            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:
@@ -1468,7 +1761,9 @@ def resolve_dependencies(par: str, resolved: list[str],
     for copy_par in copy_par_list:
         buf_in = lh5_in.get(copy_par)
         if buf_in is None:
-            log.warning(f"I don't know what to do with {copy_par}. Building output without it!")
+            log.warning(
+                f"I don't know what to do with {copy_par}. Building output without it!"
+            )
         else:
             lh5_out.add_field(copy_par, buf_in)
 
diff --git a/src/pygama/dsp/processors.py b/src/pygama/dsp/processors/__init__.py
similarity index 63%
rename from src/pygama/dsp/processors.py
rename to src/pygama/dsp/processors/__init__.py
index 09ecda024..02f1166fb 100644
--- a/src/pygama/dsp/processors.py
+++ b/src/pygama/dsp/processors/__init__.py
@@ -41,7 +41,7 @@
    vectorization on a vector-CPU. Modern CPUs typically have 256- or 512-bit
    wide processing units, which can accommodate multiple 32- or 64-bit numbers.
    Programming with these, however, is quite difficult and requires specialized
-   commands to be called.  Luckily for us, many NumPy :class`~numpy.ufunc`\ s
+   commands to be called.  Luckily for us, many NumPy :class:`~numpy.ufunc`\ s
    will automatically use these for us, speeding up our code!
 
 3. :class:`~numpy.ufunc`\ s are capable of calculating their output in place,
@@ -59,37 +59,74 @@
    to use functions that operate in place as much as possible!
 """
 
-from ._processors.bl_subtract import bl_subtract
-from ._processors.convolutions import cusp_filter, t0_filter, zac_filter
-from ._processors.fftw import dft, inv_dft, psd
-from ._processors.fixed_time_pickoff import fixed_time_pickoff
-from ._processors.gaussian_filter1d import gaussian_filter1d
-from ._processors.get_multi_local_extrema import get_multi_local_extrema
-from ._processors.linear_slope_fit import linear_slope_fit
-from ._processors.log_check import log_check
-from ._processors.min_max import min_max
-from ._processors.moving_windows import (
+from .bl_subtract import bl_subtract
+from .convolutions import cusp_filter, t0_filter, zac_filter
+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 .linear_slope_fit import linear_slope_fit
+from .log_check import log_check
+from .min_max import min_max
+from .moving_windows import (
     avg_current,
     moving_window_left,
     moving_window_multi,
     moving_window_right,
 )
-from ._processors.multi_a_filter import multi_a_filter
-from ._processors.multi_t_filter import multi_t_filter, remove_duplicates
-from ._processors.optimize import optimize_1pz, optimize_2pz
-from ._processors.param_lookup import param_lookup
-from ._processors.pole_zero import double_pole_zero, pole_zero
-from ._processors.presum import presum
-from ._processors.pulse_injector import inject_exp_pulse, inject_sig_pulse
-from ._processors.saturation import saturation
-from ._processors.soft_pileup_corr import soft_pileup_corr, soft_pileup_corr_bl
-from ._processors.time_point_thresh import time_point_thresh
-from ._processors.trap_filters import (
-    asym_trap_filter,
-    trap_filter,
-    trap_norm,
-    trap_pickoff,
-)
-from ._processors.upsampler import upsampler
-from ._processors.wiener_filter import wiener_filter
-from ._processors.windower import windower
+from .multi_a_filter import multi_a_filter
+from .multi_t_filter import multi_t_filter, remove_duplicates
+from .optimize import optimize_1pz, optimize_2pz
+from .param_lookup import param_lookup
+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_point_thresh import time_point_thresh
+from .trap_filters import asym_trap_filter, trap_filter, trap_norm, trap_pickoff
+from .upsampler import upsampler
+from .wiener_filter import wiener_filter
+from .windower import windower
+
+__all__ = [
+    "bl_subtract",
+    "cusp_filter",
+    "t0_filter",
+    "zac_filter",
+    "dft",
+    "inv_dft",
+    "psd",
+    "fixed_time_pickoff",
+    "gaussian_filter1d",
+    "get_multi_local_extrema",
+    "linear_slope_fit",
+    "log_check",
+    "min_max",
+    "avg_current",
+    "moving_window_left",
+    "moving_window_multi",
+    "moving_window_right",
+    "multi_a_filter",
+    "multi_t_filter",
+    "remove_duplicates",
+    "optimize_1pz",
+    "optimize_2pz",
+    "param_lookup",
+    "double_pole_zero",
+    "pole_zero",
+    "presum",
+    "inject_exp_pulse",
+    "inject_sig_pulse",
+    "saturation",
+    "soft_pileup_corr",
+    "soft_pileup_corr_bl",
+    "time_point_thresh",
+    "asym_trap_filter",
+    "trap_filter",
+    "trap_norm",
+    "trap_pickoff",
+    "upsampler",
+    "wiener_filter",
+    "windower",
+]
diff --git a/src/pygama/dsp/processors/bl_subtract.py b/src/pygama/dsp/processors/bl_subtract.py
new file mode 100644
index 000000000..e9a6f73c4
--- /dev/null
+++ b/src/pygama/dsp/processors/bl_subtract.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+
+@guvectorize(
+    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
+    "(n),()->(n)",
+    nopython=True,
+    cache=True,
+)
+def bl_subtract(w_in: np.ndarray, a_baseline: float, w_out: np.ndarray) -> None:
+    """Subtract the constant baseline from the entire waveform.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    a_baseline
+        the baseline value to subtract.
+    w_out
+        the output waveform with the baseline subtracted.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_bl": {
+            "function": "bl_subtract",
+            "module": "pygama.dsp.processors",
+            "args": ["waveform", "baseline", "wf_bl"],
+            "unit": "ADC"
+        }
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(a_baseline):
+        return
+
+    w_out[:] = w_in[:] - a_baseline
diff --git a/src/pygama/dsp/processors/convolutions.py b/src/pygama/dsp/processors/convolutions.py
new file mode 100644
index 000000000..9f11c54c9
--- /dev/null
+++ b/src/pygama/dsp/processors/convolutions.py
@@ -0,0 +1,263 @@
+from __future__ import annotations
+
+from typing import Callable
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+def cusp_filter(length: int, sigma: float, flat: int, decay: int) -> Callable:
+    """Apply a CUSP filter to the waveform.
+
+    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`.
+
+    Parameters
+    ----------
+    length
+        the length of the filter to be convolved.
+    sigma
+        the curvature of the rising and falling part of the kernel.
+    flat
+        the length of the flat section.
+    decay
+        the decay constant of the exponential to be convolved.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_cusp": {
+            "function": "cusp_filter",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_bl", "wf_cusp(101,f)"],
+            "unit": "ADC",
+            "init_args": ["len(wf_bl)-100", "40*us", "3*us", "45*us"]
+        }
+    """
+    if length <= 0:
+        raise DSPFatal("The length of the filter must be positive")
+
+    if sigma < 0:
+        raise DSPFatal("The curvature parameter must be positive")
+
+    if flat < 0:
+        raise DSPFatal("The length of the flat section must be positive")
+
+    if decay < 0:
+        raise DSPFatal("The decay constant must be positive")
+
+    lt = int((length - flat) / 2)
+    flat_int = int(flat)
+    cusp = np.zeros(length)
+    for ind in range(0, lt, 1):
+        cusp[ind] = float(np.sinh(ind / sigma) / np.sinh(lt / sigma))
+    for ind in range(lt, lt + flat_int + 1, 1):
+        cusp[ind] = 1
+    for ind in range(lt + flat_int + 1, length, 1):
+        cusp[ind] = float(np.sinh((length - ind) / sigma) / np.sinh(lt / sigma))
+
+    den = [1, -np.exp(-1 / decay)]
+    cuspd = np.convolve(cusp, den, "same")
+
+    @guvectorize(
+        ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
+        "(n),(m)",
+        forceobj=True,
+    )
+    def cusp_out(w_in: np.ndarray, w_out: np.ndarray) -> None:
+        """
+        Parameters
+        ----------
+        w_in
+            the input waveform.
+        w_out
+            the filtered waveform.
+        """
+        w_out[:] = np.nan
+
+        if np.isnan(w_in).any():
+            return
+
+        if len(cuspd) > len(w_in):
+            raise DSPFatal("The filter is longer than the input waveform")
+
+        w_out[:] = np.convolve(w_in, cuspd, "valid")
+
+    return cusp_out
+
+
+def zac_filter(length: int, sigma: float, flat: int, decay: int) -> Callable:
+    """Apply a ZAC (Zero Area CUSP) filter to the waveform.
+
+    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`.
+
+    Parameters
+    ----------
+    length
+        the length of the filter to be convolved.
+    sigma
+        the curvature of the rising and falling part of the kernel.
+    flat
+        the length of the flat section.
+    decay
+        the decay constant of the exponential to be convolved.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_zac": {
+            "function": "zac_filter",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_bl", "wf_zac(101,f)"],
+            "unit": "ADC",
+            "init_args": ["len(wf_bl)-100", "40*us", "3*us", "45*us"],
+        }
+    """
+    if length <= 0:
+        raise DSPFatal("The length of the filter must be positive")
+
+    if sigma < 0:
+        raise DSPFatal("The curvature parameter must be positive")
+
+    if flat < 0:
+        raise DSPFatal("The length of the flat section must be positive")
+
+    if decay < 0:
+        raise DSPFatal("The decay constant must be positive")
+
+    lt = int((length - flat) / 2)
+    flat_int = int(flat)
+
+    # calculate cusp filter and negative parables
+    cusp = np.zeros(length)
+    par = np.zeros(length)
+    for ind in range(0, lt, 1):
+        cusp[ind] = float(np.sinh(ind / sigma) / np.sinh(lt / sigma))
+        par[ind] = np.power(ind - lt / 2, 2) - np.power(lt / 2, 2)
+    for ind in range(lt, lt + flat_int + 1, 1):
+        cusp[ind] = 1
+    for ind in range(lt + flat_int + 1, length, 1):
+        cusp[ind] = float(np.sinh((length - ind) / sigma) / np.sinh(lt / sigma))
+        par[ind] = np.power(length - ind - lt / 2, 2) - np.power(lt / 2, 2)
+
+    # calculate area of cusp and parables
+    areapar, areacusp = 0, 0
+    for i in range(0, length, 1):
+        areapar += par[i]
+        areacusp += cusp[i]
+
+    # normalize parables area
+    par = -par / areapar * areacusp
+
+    # create zac filter
+    zac = cusp + par
+
+    # deconvolve zac filter
+    den = [1, -np.exp(-1 / decay)]
+    zacd = np.convolve(zac, den, "same")
+
+    @guvectorize(
+        ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
+        "(n),(m)",
+        forceobj=True,
+    )
+    def zac_out(w_in: np.ndarray, w_out: np.ndarray) -> None:
+        """
+        Parameters
+        ----------
+        w_in
+            the input waveform.
+        w_out
+            the filtered waveform.
+        """
+        w_out[:] = np.nan
+
+        if np.isnan(w_in).any():
+            return
+
+        if len(zacd) > len(w_in):
+            raise DSPFatal("The filter is longer than the input waveform")
+
+        w_out[:] = np.convolve(w_in, zacd, "valid")
+
+    return zac_out
+
+
+def t0_filter(rise: int, fall: int) -> Callable:
+    """Apply a modified, asymmetric trapezoidal filter to the waveform.
+
+    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`.
+
+    Parameters
+    ----------
+    rise
+        the length of the rise section. This is the linearly increasing
+        section of the filter that performs a weighted average.
+    fall
+        the length of the fall section. This is the simple averaging part
+        of the filter.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_t0_filter": {
+            "function": "t0_filter",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_pz", "wf_t0_filter(3748,f)"],
+            "unit": "ADC",
+            "init_args": ["128*ns", "2*us"]
+        }
+    """
+    if rise < 0:
+        raise DSPFatal("The length of the rise section must be positive")
+
+    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)))
+
+    @guvectorize(
+        ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
+        "(n),(m)",
+        forceobj=True,
+    )
+    def t0_filter_out(w_in: np.ndarray, w_out: np.ndarray) -> None:
+        """
+        Parameters
+        ----------
+        w_in
+            The input waveform
+        w_out
+            The filtered waveform
+        """
+        w_out[:] = np.nan
+
+        if np.isnan(w_in).any():
+            return
+
+        if len(t0_kern) > len(w_in):
+            raise DSPFatal("The filter is longer than the input waveform")
+
+        w_out[:] = np.convolve(w_in, t0_kern)[: len(w_in)]
+
+    return t0_filter_out
diff --git a/src/pygama/dsp/processors/fftw.py b/src/pygama/dsp/processors/fftw.py
new file mode 100644
index 000000000..d447c340a
--- /dev/null
+++ b/src/pygama/dsp/processors/fftw.py
@@ -0,0 +1,167 @@
+from __future__ import annotations
+
+from typing import Callable
+
+import numpy as np
+from numba import guvectorize
+from pyfftw import FFTW
+
+
+def dft(buf_in: np.ndarray, buf_out: np.ndarray) -> Callable:
+    """Perform discrete Fourier transforms using the FFTW library.
+
+    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`.
+
+    Note
+    ----
+    FFTW optimizes the FFT algorithm based on the size of the arrays, with SIMD
+    parallelized commands.  This optimization requires initialization, so this
+    is a factory function that returns a Numba gufunc that performs the FFT.
+    FFTW works on fixed memory buffers, so you must tell it what memory to use
+    ahead of time.  When using this with
+    :class:`~.dsp.processing_chain.ProcessingChain`, to ensure the correct
+    buffers are used, call
+    :meth:`~.dsp.processing_chain.ProcessingChain.get_variable` to give it the
+    internal memory buffer directly. With :func:`~.dsp.build_dsp.build_dsp`,
+    you can just give it the name, and it will automatically happen. The
+    possible `dtypes` for the input/outputs are:
+
+    =============================== ========= =============================== =============
+    :class:`numpy.dtype`            Size      :class:`numpy.dtype`            Size
+    ------------------------------- --------- ------------------------------- -------------
+    ``float32``/``float``           :math:`n` ``complex64``                   :math:`n/2+1`
+    ``float64``/``double``          :math:`n` ``complex128``                  :math:`n/2+1`
+    ``float128``/``longdouble``     :math:`n` ``complex256``/``clongdouble``  :math:`n/2+1`
+    ``complex64``                   :math:`n` ``complex64``                   :math:`n`
+    ``complex128``                  :math:`n` ``complex128``                  :math:`n`
+    ``complex256``/``clongdouble``  :math:`n` ``complex256``/``clongdouble``  :math:`n`
+    =============================== ========= =============================== =============
+    """
+    try:
+        dft_fun = FFTW(buf_in, buf_out, axes=(-1,), direction="FFTW_FORWARD")
+    except ValueError:
+        raise ValueError(
+            "incompatible array types/shapes. See function documentation for allowed values"
+        )
+
+    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)
+    def dft(wf_in: np.ndarray, dft_out: np.ndarray) -> None:
+        dft_fun(wf_in, dft_out)
+
+    return dft
+
+
+def inv_dft(buf_in: np.ndarray, buf_out: np.ndarray) -> Callable:
+    """Perform inverse discrete Fourier transforms using the FFTW library.
+
+    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`.
+
+    Note
+    ----
+    FFTW optimizes the FFT algorithm based on the size of the arrays, with SIMD
+    parallelized commands.  This optimization requires initialization, so this
+    is a factory function that returns a Numba gufunc that performs the FFT.
+    FFTW works on fixed memory buffers, so you must tell it what memory to use
+    ahead of time.  When using this with
+    :class:`~.dsp.processing_chain.ProcessingChain`, to ensure the correct
+    buffers are used, call
+    :meth:`~.dsp.processing_chain.ProcessingChain.get_variable` to give it the
+    internal memory buffer directly. With :func:`~.dsp.build_dsp.build_dsp`,
+    you can just give it the name, and it will automatically happen. The
+    possible `dtypes` for the input/outputs are:
+
+    =============================== ============= =============================== =========
+    :class:`numpy.dtype`            Size          :class:`numpy.dtype`            Size
+    ------------------------------- ------------- ------------------------------- ---------
+    ``complex64``                   :math:`n/2+1` ``float32``/``float``           :math:`n`
+    ``complex128``                  :math:`n/2+1` ``float64``/``double``          :math:`n`
+    ``complex256``/``clongdouble``  :math:`n/2+1` ``float128``/``longdouble``     :math:`n`
+    ``complex64``                   :math:`n`     ``complex64``                   :math:`n`
+    ``complex128``                  :math:`n`     ``complex128``                  :math:`n`
+    ``complex256``/``clongdouble``  :math:`n`     ``complex256``/``clongdouble``  :math:`n`
+    =============================== ============= =============================== =========
+    """
+    try:
+        idft_fun = FFTW(buf_in, buf_out, axes=(-1,), direction="FFTW_BACKWARD")
+    except ValueError:
+        raise ValueError(
+            "incompatible array types/shapes. See function documentation for allowed values"
+        )
+
+    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)
+    def inv_dft(wf_in: np.ndarray, dft_out: np.ndarray) -> None:
+        idft_fun(wf_in, dft_out)
+
+    return inv_dft
+
+
+def psd(buf_in: np.ndarray, buf_out: np.ndarray) -> Callable:
+    """Perform discrete Fourier transforms using the FFTW library, and use it to get
+    the power spectral density.
+
+    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`.
+
+    Note
+    ----
+    FFTW optimizes the FFT algorithm based on the size of the arrays, with SIMD
+    parallelized commands.  This optimization requires initialization, so this
+    is a factory function that returns a Numba gufunc that performs the FFT.
+    FFTW works on fixed memory buffers, so you must tell it what memory to use
+    ahead of time.  When using this with
+    :class:`~.dsp.processing_chain.ProcessingChain`, to ensure the correct
+    buffers are used, call
+    :meth:`~.dsp.processing_chain.ProcessingChain.get_variable` to give it the
+    internal memory buffer directly. With :func:`~.dsp.build_dsp.build_dsp`,
+    you can just give it the name, and it will automatically happen. The
+    possible `dtypes` for the input/outputs are:
+
+    =============================== ========= ============================ =============
+    :class:`numpy.dtype`            Size      :class:`numpy.dtype`         Size
+    ------------------------------- --------- ---------------------------- -------------
+    ``complex64``                   :math:`n` ``float32``/``float``        :math:`n`
+    ``complex128``                  :math:`n` ``float64``/``double``       :math:`n`
+    ``complex256``/``clongdouble``  :math:`n` ``float128``/``longdouble``  :math:`n`
+    ``float32``/``float``           :math:`n` ``float32``/``float``        :math:`n/2+1`
+    ``float64``/``double``          :math:`n` ``float64``/``double``       :math:`n/2+1`
+    ``float128``/``longdouble``     :math:`n` ``float128``/``longdouble``  :math:`n/2+1`
+    =============================== ========= ============================ =============
+    """
+
+    # build intermediate array for the dft, which will be abs'd to get the PSD
+    buf_dft = np.ndarray(
+        buf_out.shape, np.dtype("complex" + str(buf_out.dtype.itemsize * 16))
+    )
+    try:
+        dft_fun = FFTW(buf_in, buf_dft, axes=(-1,), direction="FFTW_FORWARD")
+    except ValueError:
+        raise ValueError(
+            "incompatible array types/shapes. See function documentation for allowed values"
+        )
+
+    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)
+    def psd(wf_in: np.ndarray, psd_out: np.ndarray) -> None:
+        dft_fun(wf_in, buf_dft)
+        np.abs(buf_dft, psd_out)
+
+    return psd
diff --git a/src/pygama/dsp/processors/fixed_time_pickoff.py b/src/pygama/dsp/processors/fixed_time_pickoff.py
new file mode 100644
index 000000000..456dfa9b5
--- /dev/null
+++ b/src/pygama/dsp/processors/fixed_time_pickoff.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
+    "(n),()->()",
+    nopython=True,
+    cache=True,
+)
+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.
+
+    If the provided index `t_in` is out of range, return :any:`numpy.nan`.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform
+    t_in
+        the waveform index to pick off
+    a_out
+        the output pick-off value
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "trapEftp": {
+            "function": "fixed_time_pickoff",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_trap", "tp_0+10*us", "trapEftp"],
+            "unit": "ADC"
+        }
+    """
+    a_out[0] = np.nan
+
+    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 int(t_in) < 0 or int(t_in) >= len(w_in):
+        return
+
+    a_out[0] = w_in[int(t_in)]
diff --git a/src/pygama/dsp/_processors/gaussian_filter1d.py b/src/pygama/dsp/processors/gaussian_filter1d.py
similarity index 76%
rename from src/pygama/dsp/_processors/gaussian_filter1d.py
rename to src/pygama/dsp/processors/gaussian_filter1d.py
index cb0f90111..6eb917449 100644
--- a/src/pygama/dsp/_processors/gaussian_filter1d.py
+++ b/src/pygama/dsp/processors/gaussian_filter1d.py
@@ -33,34 +33,36 @@
 # The only thing changed was the calculation of the convulution, which
 # originally called a function from a C library.  In this code, the convolution is
 # performed with NumPy's built in convolution function.
+from __future__ import annotations
 
 import numpy
 from numba import guvectorize
 
 
-def gaussian_filter1d(sigma, truncate):
+def gaussian_filter1d(sigma: int, truncate: float = 4.0) -> numpy.ndarray:
     """1-D Gaussian filter.
 
+    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`.
+
     Parameters
     ----------
-    %(input)s
-    sigma : scalar
+    sigma
         standard deviation for Gaussian kernel
-    truncate : float, optional
-        Truncate the filter at this many standard deviations.
-        Default is 4.0.
-
-    Returns
-    -------
-    gaussian_filter1d : ndarray
+    truncate
+        truncate the filter at this many standard deviations.
     """
+
     def _gaussian_kernel1d(sigma, radius):
         """
         Computes a 1-D Gaussian convolution kernel.
         """
         sigma2 = sigma * sigma
         x = numpy.arange(-radius, radius + 1)
-        phi_x = numpy.exp(-0.5 / sigma2 * x ** 2)
+        phi_x = numpy.exp(-0.5 / sigma2 * x**2)
         phi_x = phi_x / phi_x.sum()
         return phi_x
 
@@ -79,22 +81,16 @@ def _gaussian_kernel1d(sigma, radius):
 
     extension_length = int(len(weights) / 2) + 1
 
-    """Calculate a 1-D correlation along the given axis.
-    The lines of the array along the given axis are correlated with the
-    given weights.
-
-    Parameters
-    ----------
-    %(input)s
-    weights : array
-        1-D sequence of numbers.
-    %(mode_reflect)s
-    """
-    @guvectorize(["void(float32[:], float32[:])",
-                  "void(float64[:], float64[:])",
-                  "void(int32[:], int32[:])",
-                  "void(int64[:], int64[:])"],
-                 "(n),(m)", forceobj=True)
+    @guvectorize(
+        [
+            "void(float32[:], float32[:])",
+            "void(float64[:], float64[:])",
+            "void(int32[:], int32[:])",
+            "void(int64[:], int64[:])",
+        ],
+        "(n),(m)",
+        forceobj=True,
+    )
     def gaussian_filter1d_out(wf_in, wf_out):
 
         # Have to create an array to enable the reflect mode
@@ -105,8 +101,9 @@ def gaussian_filter1d_out(wf_in, wf_out):
         # Short warning message if kernel is larger than signal, in which case signal can't be convolved
 
         if len(wf_in) < extension_length:
-            print('Kernel calculated was larger than signal, try again with smaller parameters')
-            return 0
+            raise ValueError(
+                "Kernel calculated was larger than signal, try again with smaller parameters"
+            )
 
         # This mode extends as a reflection
         # ‘reflect’ (d c b a | a b c d | d c b a)
@@ -114,14 +111,16 @@ def gaussian_filter1d_out(wf_in, wf_out):
         # sometimes referred to as half-sample symmetric.
 
         reflected_front = numpy.flip(wf_in[0:extension_length])
-        reflected_end   = numpy.flip(wf_in[-extension_length:])
+        reflected_end = numpy.flip(wf_in[-extension_length:])
 
         # Extend the signal
 
         extended_signal = wf_in
-        extended_signal = numpy.concatenate((extended_signal, reflected_end  ), axis=None)
-        extended_signal = numpy.concatenate((reflected_front, extended_signal), axis=None)
-        output = numpy.correlate(extended_signal, weights, mode='same')
+        extended_signal = numpy.concatenate((extended_signal, reflected_end), axis=None)
+        extended_signal = numpy.concatenate(
+            (reflected_front, extended_signal), axis=None
+        )
+        output = numpy.correlate(extended_signal, weights, mode="same")
 
         # Now extract the original signal length
 
diff --git a/src/pygama/dsp/processors/get_multi_local_extrema.py b/src/pygama/dsp/processors/get_multi_local_extrema.py
new file mode 100644
index 000000000..e7ec7cef3
--- /dev/null
+++ b/src/pygama/dsp/processors/get_multi_local_extrema.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32[:], float32[:], float32[:], float32[:], float32[:])",
+        "void(float64[:], float64, float64[:], float64[:],float64[:], float64[:], float64[:])",
+    ],
+    "(n),(),(m),(m),(),(),()",
+    nopython=True,
+    cache=True,
+)
+def get_multi_local_extrema(
+    w_in: np.ndarray,
+    a_delta_in: float,
+    vt_max_out: np.ndarray,
+    vt_min_out: np.ndarray,
+    n_max_out: int,
+    n_min_out: int,
+    flag_out: int,
+) -> None:
+    """Get lists of indices of the local maxima and minima of data.
+
+    The "local" extrema are those maxima (minima) that have heights (depths) of
+    at least `a_delta_in`.  Converted from a `MATLAB script
+    <http://billauer.co.il/peakdet.html>`_ by E. Billauer.
+
+    Parameters
+    ----------
+    w_in
+        the array of data within which extrema will be found.
+    a_delta_in
+        the absolute level by which data must vary (in one direction) about an
+        extremum in order for it 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.
+    n_max_out, n_min_out
+        the number of maxima and minima found in a waveform.
+    flag_out
+        returns ``1`` if there is only one maximum and it is a simple waveform,
+        returns ``0`` if there are no peaks, or multiple peaks in a waveform.
+    """
+
+    # prepare output
+    vt_max_out[:] = np.nan
+    vt_min_out[:] = np.nan
+    n_max_out[0] = np.nan
+    n_min_out[0] = np.nan
+    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
+
+    if not len(vt_max_out) < len(w_in) or not len(vt_min_out) < len(w_in):
+        raise DSPFatal(
+            "The length of your return array must be smaller than the length of your waveform"
+        )
+    if not a_delta_in >= 0:
+        raise DSPFatal("a_delta_in must be positive")
+
+    # now loop over data
+
+    imax, imin = 0, 0
+    find_max = True
+    for i in range(len(w_in)):
+
+        if w_in[i] > w_in[imax]:
+            imax = i
+        if w_in[i] < w_in[imin]:
+            imin = i
+
+        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)
+            ):
+                vt_max_out[int(n_max_counter)] = imax
+                n_max_counter += 1
+                imin = i
+                find_max = False
+        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)
+            ):
+                vt_min_out[int(n_min_counter)] = imin
+                n_min_counter += 1
+                imax = i
+                find_max = True
+
+    # set output
+    n_max_out[0] = n_max_counter
+    n_min_out[0] = n_min_counter
+
+    if n_max_out[0] == 1:
+        flag_out[0] = 1
+    else:
+        flag_out[0] = 0
diff --git a/src/pygama/dsp/_processors/linear_slope_fit.py b/src/pygama/dsp/processors/linear_slope_fit.py
similarity index 50%
rename from src/pygama/dsp/_processors/linear_slope_fit.py
rename to src/pygama/dsp/processors/linear_slope_fit.py
index 5bde7cd00..588379833 100644
--- a/src/pygama/dsp/_processors/linear_slope_fit.py
+++ b/src/pygama/dsp/processors/linear_slope_fit.py
@@ -1,13 +1,21 @@
+from __future__ import annotations
+
 import numpy as np
 from numba import guvectorize
 
-from pygama.dsp.errors import DSPFatal
-
 
-@guvectorize(["void(float32[:], float32[:], float32[:], float32[:], float32[:])",
-              "void(float64[:], float64[:], float64[:], float64[:], float64[:])"],
-             "(n)->(),(),(),()", nopython=True, cache=True)
-def linear_slope_fit(w_in, mean, stdev, slope, intercept):
+@guvectorize(
+    [
+        "void(float32[:], float32[:], float32[:], float32[:], float32[:])",
+        "void(float64[:], float64[:], float64[:], float64[:], float64[:])",
+    ],
+    "(n)->(),(),(),()",
+    nopython=True,
+    cache=True,
+)
+def linear_slope_fit(
+    w_in: np.ndarray, mean: float, stdev: float, slope: float, intercept: float
+) -> None:
     """
     Calculate the mean and standard deviation of the waveform using
     Welford's method as well as the slope an intercept of the waveform
@@ -15,19 +23,20 @@ def linear_slope_fit(w_in, mean, stdev, slope, intercept):
 
     Parameters
     ----------
-    w_in : array-like
-        The input waveform
-    mean : float
-        The mean of the waveform
-    stdev : float
-        The standard deviation of the waveform
-    slope : float
-        The slope of the linear fit
-    intercept : float
-        The intercept of the linear fit
+    w_in
+        the input waveform.
+    mean
+        the mean of the waveform.
+    stdev
+        the standard deviation of the waveform.
+    slope
+        the slope of the linear fit.
+    intercept
+        the intercept of the linear fit.
+
+    JSON Configuration Example
+    --------------------------
 
-    Examples
-    --------
     .. code-block :: json
 
         "bl_mean, bl_std, bl_slope, bl_intercept": {
@@ -35,12 +44,11 @@ def linear_slope_fit(w_in, mean, stdev, slope, intercept):
             "module": "pygama.dsp.processors",
             "args": ["wf_blsub[0:1650]", "bl_mean", "bl_std", "bl_slope", "bl_intercept"],
             "unit": ["ADC", "ADC", "ADC", "ADC"],
-            "prereqs": ["wf_blsub"]
         }
     """
-    mean     [0] = np.nan
-    stdev    [0] = np.nan
-    slope    [0] = np.nan
+    mean[0] = np.nan
+    stdev[0] = np.nan
+    slope[0] = np.nan
     intercept[0] = np.nan
 
     if np.isnan(w_in).any():
@@ -51,18 +59,18 @@ def linear_slope_fit(w_in, mean, stdev, slope, intercept):
 
     for i in range(0, len(w_in), 1):
         # the mean and standard deviation
-        temp   = w_in[i] - mean
-        mean  += temp / (i + 1)
+        temp = w_in[i] - mean
+        mean += temp / (i + 1)
         stdev += temp * (w_in[i] - mean)
 
         # linear regression
-        sum_x  += i
+        sum_x += i
         sum_x2 += i * i
-        sum_xy += (w_in[i] * i)
-        sum_y  += w_in[i]
+        sum_xy += w_in[i] * i
+        sum_y += w_in[i]
 
-    stdev /= (isum - 1)
+    stdev /= isum - 1
     np.sqrt(stdev, stdev)
 
-    slope    [0] = (isum * sum_xy - sum_x * sum_y) / (isum * sum_x2 - sum_x * sum_x)
+    slope[0] = (isum * sum_xy - sum_x * sum_y) / (isum * sum_x2 - sum_x * sum_x)
     intercept[0] = (sum_y - sum_x * slope[0]) / isum
diff --git a/src/pygama/dsp/_processors/log_check.py b/src/pygama/dsp/processors/log_check.py
similarity index 56%
rename from src/pygama/dsp/_processors/log_check.py
rename to src/pygama/dsp/processors/log_check.py
index e2682b235..d6a914e57 100644
--- a/src/pygama/dsp/_processors/log_check.py
+++ b/src/pygama/dsp/processors/log_check.py
@@ -1,34 +1,37 @@
+from __future__ import annotations
+
 import numpy as np
 from numba import guvectorize
 
-from pygama.dsp.errors import DSPFatal
-
 
-@guvectorize(["void(float32[:], float32[:])",
-              "void(float64[:], float64[:])"],
-             "(n)->(n)", nopython=True, cache=True)
-def log_check(w_in, w_log):
+@guvectorize(
+    ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
+    "(n)->(n)",
+    nopython=True,
+    cache=True,
+)
+def log_check(w_in: np.ndarray, w_log: np.ndarray) -> None:
     """
     Calculate the logarithm of the waveform if all its values
     are positive; otherwise, return NaN.
 
     Parameters
     ----------
-    w_in : array-like
-        The input waveform
-    w_log : array-like
-        The output waveform with logged values
+    w_in
+        the input waveform.
+    w_log
+        the output waveform with logged values.
+
+    JSON Configuration Example
+    --------------------------
 
-    Examples
-    --------
     .. code-block :: json
 
         "wf_logged": {
             "function": "log_check",
             "module": "pygama.dsp.processors",
             "args": ["wf_blsub[2100:]", "wf_logged"],
-            "unit": "ADC",
-            "prereqs": ["wf_blsub"]
+            "unit": "ADC"
         }
     """
     w_log[:] = np.nan
diff --git a/src/pygama/dsp/processors/min_max.py b/src/pygama/dsp/processors/min_max.py
new file mode 100644
index 000000000..75e7076e6
--- /dev/null
+++ b/src/pygama/dsp/processors/min_max.py
@@ -0,0 +1,71 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32[:], float32[:], float32[:], float32[:])",
+        "void(float64[:], float64[:], float64[:], float64[:], float64[:])",
+    ],
+    "(n)->(),(),(),()",
+    nopython=True,
+    cache=True,
+)
+def min_max(
+    w_in: np.ndarray, t_min: int, t_max: int, a_min: float, a_max: float
+) -> None:
+    """Find the value and index of the minimum and maximum values
+    in the waveform.
+
+    Note
+    ----
+    The first found instance of each extremum in the waveform will be returned.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform
+    t_min
+        the index of the minimum value
+    t_max
+        the index of the maximum value
+    a_min
+        the minimum value
+    a_max
+        the maximum value
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "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"]
+        }
+    """
+    a_min[0] = np.nan
+    a_max[0] = np.nan
+    t_min[0] = np.nan
+    t_max[0] = np.nan
+
+    if np.isnan(w_in).any():
+        return
+
+    min_index = 0
+    max_index = 0
+
+    for i in range(0, len(w_in), 1):
+        if w_in[i] < w_in[min_index]:
+            min_index = i
+        if w_in[i] > w_in[max_index]:
+            max_index = i
+
+    a_min[0] = w_in[min_index]
+    a_max[0] = w_in[max_index]
+    t_min[0] = float(min_index)
+    t_max[0] = float(max_index)
diff --git a/src/pygama/dsp/processors/moving_windows.py b/src/pygama/dsp/processors/moving_windows.py
new file mode 100644
index 000000000..b000fab42
--- /dev/null
+++ b/src/pygama/dsp/processors/moving_windows.py
@@ -0,0 +1,242 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
+    "(n),()->(n)",
+    nopython=True,
+    cache=True,
+)
+def moving_window_left(w_in: np.ndarray, length: float, w_out: np.ndarray) -> None:
+    """Applies a moving average window to the waveform.
+
+    Note
+    ----
+    Starts from the left and assumes that the baseline is at zero.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    length
+        length of the moving window to be applied.
+    w_out
+        output waveform after moving window applied.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_mw": {
+            "function": "moving_window_left",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_pz", "96*ns", "wf_mw"],
+            "unit": "ADC"
+        }
+    """
+
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any():
+        return
+
+    if not length >= 0 or not length < len(w_in):
+        raise DSPFatal(
+            "length is out of range, must be between 0 and the length of the waveform"
+        )
+
+    w_out[0] = w_in[0]
+    for i in range(1, int(length)):
+        w_out[i] = w_out[i - 1] + (w_in[i] - w_in[0]) / length
+    for i in range(int(length), len(w_in)):
+        w_out[i] = w_out[i - 1] + (w_in[i] - w_in[i - int(length)]) / length
+
+
+@guvectorize(
+    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
+    "(n),()->(n)",
+    nopython=True,
+    cache=True,
+)
+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.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    length
+        length of the moving window to be applied.
+    w_out
+        output waveform after moving window applied.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_mw": {
+            "function": "moving_window_right",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_pz", "96*ns", "wf_mw"],
+            "unit": "ADC"
+        }
+    """
+
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any():
+        return
+
+    if not length >= 0 or not length < len(w_in):
+        raise DSPFatal(
+            "length is out of range, must be between 0 and the length of the waveform"
+        )
+
+    w_out[-1] = w_in[-1]
+    for i in range(1, int(length), 1):
+        w_out[len(w_in) - 1 - i] = (
+            w_out[len(w_in) - i] + (w_in[len(w_in) - 1 - i] - w_out[-1]) / length
+        )
+    for i in range(int(length), len(w_in), 1):
+        w_out[len(w_in) - 1 - i] = (
+            w_out[len(w_in) - i]
+            + (w_in[len(w_in) - 1 - i] - w_in[len(w_in) - 1 - i + int(length)]) / length
+        )
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, int32, float32[:])",
+        "void(float64[:], float64, float64, int32, float64[:])",
+    ],
+    "(n),(),(),()->(n)",
+    nopython=True,
+    cache=True,
+)
+def moving_window_multi(
+    w_in: np.ndarray, length: float, num_mw: int, mw_type: int, w_out: np.ndarray
+) -> None:
+    """Apply a series of moving-average windows to the waveform, alternating
+    its application between the left and the right.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    length
+        length of the moving window to be applied.
+    num_mw
+        the number of moving windows.
+    mw_type
+        - ``0`` -- alternate moving windows right and left
+        - ``1`` -- only left
+        - ``2`` -- only right
+    w_out
+        the windowed waveform.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "curr_av": {
+            "function": "moving_window_multi",
+            "module": "pygama.dsp.processors",
+            "args": ["curr", "96*ns", "3", "0", "curr_av"],
+            "unit": "ADC/sample"
+        }
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any():
+        return
+
+    if np.floor(length) != length:
+        raise DSPFatal("The length of the moving window must be an integer")
+
+    if np.floor(num_mw) != num_mw:
+        raise DSPFatal("The number of moving windows must be an integer")
+
+    if int(length) < 0 or int(length) >= len(w_in):
+        raise DSPFatal("The length of the moving window is out of range")
+
+    if int(num_mw) < 0:
+        raise DSPFatal("The number of moving windows much be positive")
+
+    w_buf = w_in.copy()
+    for i in range(0, int(num_mw), 1):
+        if ((i % 2 == 1) & (mw_type == 0)) | (mw_type == 2):
+            w_out[-1] = w_buf[-1]
+            for i in range(1, int(length), 1):
+                w_out[len(w_buf) - 1 - i] = (
+                    w_out[len(w_buf) - i]
+                    + (w_buf[len(w_buf) - 1 - i] - w_out[-1]) / length
+                )
+            for i in range(int(length), len(w_buf), 1):
+                w_out[len(w_buf) - 1 - i] = (
+                    w_out[len(w_buf) - i]
+                    + (
+                        w_buf[len(w_buf) - 1 - i]
+                        - w_buf[len(w_buf) - 1 - i + int(length)]
+                    )
+                    / length
+                )
+        else:
+            w_out[0] = w_buf[0]
+            for i in range(1, int(length)):
+                w_out[i] = w_out[i - 1] + (w_buf[i] - w_buf[0]) / length
+            for i in range(int(length), len(w_buf)):
+                w_out[i] = w_out[i - 1] + (w_buf[i] - w_buf[i - int(length)]) / length
+        w_buf = w_out.copy()
+
+
+@guvectorize(
+    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
+    "(n),(),(m)",
+    nopython=True,
+    cache=True,
+)
+def avg_current(w_in: np.ndarray, length: float, w_out: np.ndarray) -> None:
+    """Calculate the derivative of a waveform, averaged across `length` samples.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    length
+        length of the moving window to be applied.
+    w_out
+        output waveform after derivation.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "curr": {
+            "function": "avg_current",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_pz", 1, "curr(len(wf_pz)-1, f)"],
+            "unit": "ADC/sample"
+        }
+    """
+
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any():
+        return
+
+    if not length >= 0 or not length < len(w_in):
+        raise DSPFatal(
+            "length is out of range, must be between 0 and the length of the waveform"
+        )
+
+    w_out[:] = w_in[int(length) :] - w_in[: -int(length)]
+    w_out /= length
diff --git a/src/pygama/dsp/_processors/multi_a_filter.py b/src/pygama/dsp/processors/multi_a_filter.py
similarity index 62%
rename from src/pygama/dsp/_processors/multi_a_filter.py
rename to src/pygama/dsp/processors/multi_a_filter.py
index c1eaa2991..73cfe736b 100644
--- a/src/pygama/dsp/_processors/multi_a_filter.py
+++ b/src/pygama/dsp/processors/multi_a_filter.py
@@ -1,13 +1,20 @@
 import numpy as np
 from numba import guvectorize
 
-from pygama.dsp._processors.fixed_time_pickoff import fixed_time_pickoff
 from pygama.dsp.errors import DSPFatal
 
+from .fixed_time_pickoff import fixed_time_pickoff
 
-@guvectorize(["void(float32[:], float32[:], float32[:])",
-              "void(float64[:], float64[:], float64[:])"],
-             "(n),(m),(m)", forceobj=True, cache=True)
+
+@guvectorize(
+    [
+        "void(float32[:], float32[:], float32[:])",
+        "void(float64[:], float64[:], float64[:])",
+    ],
+    "(n),(m),(m)",
+    forceobj=True,
+    cache=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
@@ -28,10 +35,12 @@ def multi_a_filter(w_in, vt_maxs_in, va_max_out):
 
     # Check inputs
 
-    if (np.isnan(w_in).any()):
+    if np.isnan(w_in).any():
         return
 
-    if (not len(vt_maxs_in)<len(w_in)):
-        raise DSPFatal('The length of your return array must be smaller than the length of your waveform')
+    if not len(vt_maxs_in) < len(w_in):
+        raise DSPFatal(
+            "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)
diff --git a/src/pygama/dsp/processors/multi_t_filter.py b/src/pygama/dsp/processors/multi_t_filter.py
new file mode 100644
index 000000000..cd0a18e22
--- /dev/null
+++ b/src/pygama/dsp/processors/multi_t_filter.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+from .time_point_thresh import time_point_thresh
+
+
+@guvectorize(
+    [
+        "void(float32[:],float32[:],float32[:])",
+        "void(float64[:],float64[:],float64[:])",
+    ],
+    "(n),(n) -> (n)",
+    nopython=True,
+    cache=True,
+)
+def remove_duplicates(
+    t_in: np.ndarray, vt_min_in: np.ndarray, t_out: np.ndarray
+) -> None:
+    """Helper function to remove duplicate peak positions.
+
+    :func:`.time_point_thresh` has issues with afterpulsing in waveforms that
+    causes an aferpulse peak position to be sent to zero or the same index as
+    the first pulse. This only happens when the relative minimum between the
+    first pulse and the afterpulse is greater than the threshold. So, we sweep
+    through the array again to ensure there are no duplicate indices. If there
+    are duplicate indices caused by a misidentified position of an afterpulse,
+    we replace its index by that of the corresponding minimum found using
+    :func:`.get_multi_local_extrema`. It also checks to make sure that the
+    maximum of a waveform isn't right at index 0.
+
+    Parameters
+    ----------
+    t_in
+        the array of indices that we want to remove duplicates from.
+    vt_min_in
+        list of indices of minima that we want to replace duplicates in `t_out`
+        with.
+    t_out
+        the array we want to return that will have no duplicate indices in it.
+
+    See Also
+    --------
+    .multi_t_filter
+    """
+    # initialize arrays
+    t_out[:] = np.nan
+
+    # checks
+    if (
+        np.isnan(t_in).all() and np.isnan(vt_min_in).all()
+    ):  # we pad these with NaNs, so only return if there is nothing to analyze
+        return
+
+    # check if any later indexed values are equal to the earliest instance
+    k = 0
+    for index1 in range(len(t_in)):
+        for index2 in range(len(t_in[index1 + 1 :])):
+            if t_in[index1] == t_in[index2 + index1 + 1]:
+                t_out[index2 + index1 + 1] = vt_min_in[k]
+        k += 1  # this makes sure that the index of the misidentified afterpulse tp0 is replaced with the correct corresponding minimum
+
+    # Fill up the output with the rest of the values from the input that weren't repeats
+    for index in range(len(t_in)):
+        if np.isnan(t_out[index]) and not np.isnan(t_in[index]):
+            t_out[index] = t_in[index]
+
+    # makes sure that the first maximum found isn't the start of the waveform
+    if not np.isnan(t_out[0]):
+        if int(t_out[0]) == 0:
+            t_out[:] = np.append(t_out[1:], np.nan)
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32[:], float32[:], float32[:], float32[:])",
+        "void(float64[:], float64[:], float64[:], float64[:], float64[:])",
+    ],
+    "(n),(),(m),(m),(m)",
+    forceobj=True,
+    cache=True,
+)
+def multi_t_filter(
+    w_in: np.ndarray,
+    a_threshold_in: float,
+    vt_max_in: np.ndarray,
+    vt_min_in: np.ndarray,
+    t_out: np.ndarray,
+) -> None:
+    """Gets list of indices of the start of leading edges of multiple peaks
+    within a waveform.
+
+    Is built to handle afterpulses/delayed cross-talk and trains of pulses.
+    Works by calling the vectorized functions :func:`.get_multi_local_extrema`
+    which returns a list of the maxima and minima in a waveform, and then the
+    list of maxima is fed into :func:`.time_point_thresh` which returns the
+    final times that waveform is less than a specified threshold.
+
+    Parameters
+    ----------
+    w_in
+        the array of data within which the list of leading edge times will be
+        found.
+    a_threshold_in
+        threshold to search for using :func:`.time_point_thresh`.
+    vt_max_in
+        the array of maximum positions for each waveform.
+    vt_min_in
+        the array of minimum positions for each waveform.
+    t_out
+        output array of fixed length (padded with :any:`numpy.nan`) that hold
+        the indices of the identified initial rise times of peaks in the
+        signal.
+
+    See Also
+    --------
+    ~.time_point_thresh.time_point_thresh
+    """
+
+    # initialize arrays, padded with the elements we want
+    t_out[:] = np.nan
+
+    # checks
+    if np.isnan(w_in).any() or np.isnan(a_threshold_in):
+        return
+    if np.isnan(vt_max_in).all() and np.isnan(vt_min_in).all():
+        return
+    if not len(t_out) <= len(w_in):
+        raise DSPFatal(
+            "The length of your return array must be smaller than the length of your waveform"
+        )
+
+    # Initialize an intermediate array to hold the tp0 values before we remove duplicates from it
+    intermediate_t_out = np.full_like(t_out, np.nan, dtype=np.float32)
+
+    # Go through the list of maxima, calling time_point_thresh (the refactored version ignores the nan padding)
+    time_point_thresh(w_in, a_threshold_in, vt_max_in, 0, intermediate_t_out)
+
+    # Remove duplicates from the t_out list
+    remove_duplicates(intermediate_t_out, vt_min_in, t_out)
diff --git a/src/pygama/dsp/processors/optimize.py b/src/pygama/dsp/processors/optimize.py
new file mode 100644
index 000000000..f3839621c
--- /dev/null
+++ b/src/pygama/dsp/processors/optimize.py
@@ -0,0 +1,220 @@
+from __future__ import annotations
+
+from typing import Callable
+
+import numpy as np
+from iminuit import Minuit
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+from .pole_zero import double_pole_zero, pole_zero
+
+
+class Model:
+    """A class representing the function to minimize."""
+
+    errordef = Minuit.LEAST_SQUARES
+
+    # the constructor
+    def __init__(
+        self, func: Callable, w_in: np.ndarray, baseline: float, beg: int, end: int
+    ) -> None:
+        self.func = func
+        self.x = np.arange(beg, end)
+        self.y = np.asarray(w_in, dtype=np.float64) - baseline
+        self.beg = beg
+        self.end = end
+
+    # the function to minimize
+    def __call__(self, args) -> np.ndarray:
+        y_pz = self.func(self.y, *args)[self.beg : self.end]
+        return np.abs(
+            np.sum(self.x) * np.sum(y_pz) - len(self.x) * np.sum(self.x * y_pz)
+        )
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, float32, float32, float32[:])",
+        "void(float64[:], float64, float64, float64, float64, float64[:])",
+    ],
+    "(n),(),(),(),()->()",
+    forceobj=True,
+)
+def optimize_1pz(
+    w_in: np.ndarray,
+    a_baseline_in: float,
+    t_beg_in: int,
+    t_end_in: int,
+    p0_in: float,
+    val0_out: float,
+) -> None:
+    """Find the optimal, single pole-zero cancellation's parameter
+    by minimizing the slope in the waveform's specified time range.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    a_baseline_in
+        the resting baseline.
+    t_beg_in
+        the lower bound's index for the time range over
+        which to optimize the pole-zero cancellation.
+    t_end_in
+        the upper bound's index for the time range over
+        which to optimize the pole-zero cancellation.
+    p0_in
+        the initial guess of the optimal time constant.
+    val0_out
+        the output value of the best-fit time constant.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "tau0": {
+            "function": "optimize_1pz",
+            "module": "pygama.dsp.processors",
+            "args": ["waveform", "baseline", "0", "20*us", "500*us", "tau0"],
+            "unit": "us"
+        }
+
+    See Also
+    --------
+    ~.pole_zero.pole_zero, .double_pole_zero
+    """
+    val0_out[0] = np.nan
+
+    if (
+        np.isnan(w_in).any()
+        or np.isnan(a_baseline_in)
+        or np.isnan(t_beg_in)
+        or np.isnan(t_end_in)
+        or np.isnan(p0_in)
+    ):
+        return
+
+    if not np.floor(t_beg_in) == t_beg_in or not np.floor(t_end_in) == t_end_in:
+        raise DSPFatal("The waveform index is not an integer")
+
+    if (
+        int(t_beg_in) < 0
+        or int(t_beg_in) > len(w_in)
+        or int(t_end_in) < 0
+        or int(t_end_in) > len(w_in)
+    ):
+        raise DSPFatal("The waveform index is out of range")
+
+    m = Minuit(
+        Model(pole_zero, w_in, a_baseline_in, int(t_beg_in), int(t_end_in)), [p0_in]
+    )
+    m.print_level = -1
+    m.strategy = 1
+    m.errordef = Minuit.LEAST_SQUARES
+    m.migrad()
+    val0_out[0] = m.values[0]
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, float32, float32, float32, float32, float32[:], float32[:], float32[:])",
+        "void(float64[:], float64, float64, float64, float64, float64, float64, float64[:], float64[:], float64[:])",
+    ],
+    "(n),(),(),(),(),(),()->(),(),()",
+    forceobj=True,
+)
+def optimize_2pz(
+    w_in: np.ndarray,
+    a_baseline_in: float,
+    t_beg_in: int,
+    t_end_in: int,
+    p0_in: float,
+    p1_in: float,
+    p2_in: float,
+    val0_out: float,
+    val1_out: float,
+    val2_out: float,
+) -> None:
+    """Find the optimal, double pole-zero cancellation's parameters by
+    minimizing the slope in the waveform's specified time range.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    a_baseline_in
+        the resting baseline.
+    t_beg_in
+        the lower bound's index for the time range over
+        which to optimize the pole-zero cancellation.
+    t_end_in
+        the upper bound's index for the time range over
+        which to optimize the pole-zero cancellation.
+    p0_in
+        the initial guess of the optimal, longer time constant.
+    p1_in
+        the initial guess of the optimal, shorter time constant.
+    p2_in
+        the initial guess of the optimal fraction.
+    val0_out
+        the output value of the best-fit, longer time constant.
+    val1_out
+        the output value of the best-fit, shorter time constant.
+    val2_out
+        the output value of the best-fit fraction.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "tau1, tau2, frac": {
+            "function": "optimize_2pz",
+            "module": "pygama.dsp.processors",
+            "args": [
+                "waveform", "baseline", "0", "20*us", "500*us",
+                "20*us", "0.02", "tau1", "tau2", "frac"
+            ],
+            "unit": "us"
+        }
+    """
+    val0_out[0] = np.nan
+    val1_out[0] = np.nan
+    val2_out[0] = np.nan
+
+    if (
+        np.isnan(w_in).any()
+        or np.isnan(a_baseline_in)
+        or np.isnan(t_beg_in)
+        or np.isnan(t_end_in)
+        or np.isnan(p0_in)
+        or np.isnan(p1_in)
+        or np.isnan(p2_in)
+    ):
+        return
+
+    if not np.floor(t_beg_in) == t_beg_in or not np.floor(t_end_in) == t_end_in:
+        raise DSPFatal("The waveform index is not an integer")
+
+    if (
+        int(t_beg_in) < 0
+        or int(t_beg_in) > len(w_in)
+        or int(t_end_in) < 0
+        or int(t_end_in) > len(w_in)
+    ):
+        raise DSPFatal("The waveform index is out of range")
+
+    m = Minuit(
+        Model(double_pole_zero, w_in, a_baseline_in, int(t_beg_in), int(t_end_in)),
+        [p0_in, p1_in, p2_in],
+    )
+    m.print_level = -1
+    m.strategy = 1
+    m.errordef = Minuit.LEAST_SQUARES
+    m.migrad()
+    val0_out[0] = m.values[0]
+    val1_out[0] = m.values[1]
+    val2_out[0] = m.values[2]
diff --git a/src/pygama/dsp/processors/param_lookup.py b/src/pygama/dsp/processors/param_lookup.py
new file mode 100644
index 000000000..36c2ae2ca
--- /dev/null
+++ b/src/pygama/dsp/processors/param_lookup.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import from_dtype, guvectorize, types
+
+
+def param_lookup(
+    param_dict: dict[int, float], default_val: float, dtype: str | np.dtype
+) -> np.ufunc:
+    """Generate the :class:`numpy.ufunc` ``lookup(channel, val)``, which
+    returns a NumPy array of values corresponding to various channels that are
+    looked up in the provided `param_dict`.  If there is no key, use
+    `default_val` instead.
+    """
+    out_type = from_dtype(np.dtype(dtype))
+
+    # convert types to avoid any necessity of casting
+    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)
+    def lookup(channel: int, val: np.ndarray) -> None:
+        """Look up a value for the provided channel from a dictionary provided
+        at compile time.
+        """
+        val[0] = param_dict.get(channel, default_val)
+
+    return lookup
diff --git a/src/pygama/dsp/processors/pole_zero.py b/src/pygama/dsp/processors/pole_zero.py
new file mode 100644
index 000000000..7328a8c98
--- /dev/null
+++ b/src/pygama/dsp/processors/pole_zero.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
+    "(n),()->(n)",
+    nopython=True,
+    cache=True,
+)
+def pole_zero(w_in: np.ndarray, t_tau: float, w_out: np.ndarray) -> None:
+    """Apply a pole-zero cancellation using the provided time
+    constant to the waveform.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    t_tau
+        the time constant of the exponential to be deconvolved.
+    w_out
+        the pole-zero cancelled waveform.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_pz": {
+            "function": "pole_zero",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_bl", "400*us", "wf_pz"],
+            "unit": "ADC"
+        }
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(t_tau):
+        return
+
+    const = np.exp(-1 / t_tau)
+    w_out[0] = w_in[0]
+    for i in range(1, len(w_in), 1):
+        w_out[i] = w_out[i - 1] + w_in[i] - w_in[i - 1] * const
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, float32, float32[:])",
+        "void(float64[:], float64, float64, float64, float64[:])",
+    ],
+    "(n),(),(),()->(n)",
+    nopython=True,
+    cache=True,
+)
+def double_pole_zero(
+    w_in: np.ndarray, t_tau1: float, t_tau2: float, frac: float, w_out: np.ndarray
+) -> np.ndarray:
+    r"""
+    Apply a double pole-zero cancellation using the provided time
+    constants to the waveform.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    t_tau1
+        the time constant of the first exponential to be deconvolved.
+    t_tau2
+        the time constant of the second exponential to be deconvolved.
+    frac
+        the fraction which the second exponential contributes.
+    w_out
+        the pole-zero cancelled waveform.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_pz": {
+            "function": "double_pole_zero",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_bl", "400*us", "20*us", "0.02", "wf_pz"],
+            "unit": "ADC"
+        }
+
+    Notes
+    -----
+    This algorithm is an IIR filter to deconvolve the function
+
+    .. math::
+        s(t) = A \left[ f \cdot \exp\left(-\frac{t}{\tau_2} \right)
+               + (1-f) \cdot \exp\left(-\frac{t}{\tau_1}\right) \right]
+
+    (:math:`f` = `frac`) into a single step function of amplitude :math:`A`.
+    This filter is derived by :math:`z`-transforming the input (:math:`s(t)`)
+    and output (step function) signals, and then determining the transfer
+    function. For shorthand, define :math:`a=\exp(-1/\tau_1)` and
+    :math:`b=\exp(-1/\tau_2)`, the transfer function is then:
+
+    .. math::
+        H(z) = \frac{1 - (a+b)z^{-1} + abz^{-2}}
+                    {1 + (fb - fa - b - 1)z^{-1}-(fb - fa - b)z^{-2}}
+
+    By equating the transfer function to the ratio of output to input waveforms
+    :math:`H(z) = w_\text{out}(z) / w_\text{in}(z)` and then taking the
+    inverse :math:`z`-transform yields the filter as run in the function, where
+    :math:`f` is the `frac` parameter:
+
+    .. math::
+        w_\text{out}[n] =& w_\text{in}[n] - (a+b)w_\text{in}[n-1]
+                           + abw_\text{in}[n-2] \\
+                         & -(fb - fa - b - 1)w_\text{out}[n-1]
+                           + (fb - fa - b)w_\text{out}[n-2]
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(t_tau1) or np.isnan(t_tau2) or np.isnan(frac):
+        return
+    if len(w_in) <= 3:
+        raise DSPFatal(
+            "The length of the waveform must be larger than 3 for the filter to work safely"
+        )
+
+    a = np.exp(-1 / t_tau1)
+    b = np.exp(-1 / t_tau2)
+
+    transfer_denom_1 = frac * b - frac * a - b - 1
+    transfer_denom_2 = -1 * (frac * b - frac * a - b)
+    transfer_num_1 = -1 * (a + b)
+    transfer_num_2 = a * b
+
+    w_out[0] = w_in[0]
+    w_out[1] = w_in[1]
+    w_out[2] = w_in[2]
+
+    for i in range(2, len(w_in), 1):
+        w_out[i] = (
+            w_in[i]
+            + transfer_num_1 * w_in[i - 1]
+            + transfer_num_2 * w_in[i - 2]
+            - transfer_denom_1 * w_out[i - 1]
+            - transfer_denom_2 * w_out[i - 2]
+        )
diff --git a/src/pygama/dsp/processors/presum.py b/src/pygama/dsp/processors/presum.py
new file mode 100644
index 000000000..0157c393e
--- /dev/null
+++ b/src/pygama/dsp/processors/presum.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+
+@guvectorize(
+    ["void(float32[:], float32[:])", "void(float64[:], float64[:])"],
+    "(n),(m)",
+    nopython=True,
+    cache=True,
+)
+def presum(w_in: np.ndarray, w_out: np.ndarray) -> None:
+    """Presum the waveform.
+
+    Combine bins in chunks of ``len(w_in) / len(w_out)``, which is hopefully an
+    integer. If it isn't, then some samples at the end will be omitted.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    w_out
+        the output waveform.
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any():
+        return
+
+    ps_fact = len(w_in) // len(w_out)
+    for i in range(0, len(w_out), 1):
+        j0 = i * ps_fact
+        w_out[i] = w_in[j0] / ps_fact
+        for j in range(j0 + 1, j0 + ps_fact, 1):
+            w_out[i] += w_in[j] / ps_fact
diff --git a/src/pygama/dsp/processors/pulse_injector.py b/src/pygama/dsp/processors/pulse_injector.py
new file mode 100644
index 000000000..f4c7c739c
--- /dev/null
+++ b/src/pygama/dsp/processors/pulse_injector.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from math import exp, log
+
+import numpy as np
+from numba import guvectorize
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, float32, float32, float32[:])",
+        "void(float64[:], float64, float64,float64,float64,float64[:])",
+    ],
+    "(n),(),(),(), ()->(n)",
+    nopython=True,
+    cache=True,
+)
+def inject_sig_pulse(
+    wf_in: np.ndarray, t0: int, rt: float, a: float, decay: float, wf_out: np.ndarray
+) -> None:
+    r"""Inject sigmoid pulse into existing waveform to simulate pileup.
+
+    .. math::
+        s(t) = \frac{A}{1 + \exp[-4 \log(99) (t - t_0 - t_r/2) / t_r]}
+                e^{-(t-t_0)/\tau}
+
+    Parameters
+    ----------
+    wf_in
+        the input waveform.
+    t0
+        the position :math:`t_0` of the injected waveform.
+    rt
+        the rise time :math:`t_r` of the injected waveform.
+    a
+        the amplitude :math:`A` of the injected waveform.
+    decay
+        the decay parameter :math:`\tau` of the injected waveform.
+    wf_out
+        the output waveform.
+    """
+
+    wf_out[:] = np.nan
+
+    if (
+        np.isnan(wf_in).any()
+        or np.isnan(rt)
+        or np.isnan(t0)
+        or np.isnan(a)
+        or np.isnan(decay)
+    ):
+        return
+
+    rise = 4 * log(99) / rt
+
+    wf_out[:] = wf_in[:]
+    for t in range(len(wf_out)):
+        wf_out[t] = wf_out[t] + a / (1 + exp(-rise * (t - (t0 + rt / 2)))) * exp(
+            -(1 / decay) * (t - t0)
+        )
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, float32, float32, float32[:])",
+        "void(float64[:], float64, float64,float64,float64,float64[:])",
+    ],
+    "(n),(),(),(), ()->(n)",
+    nopython=True,
+    cache=True,
+)
+def inject_exp_pulse(
+    wf_in: np.ndarray, t0: int, rt: float, a: float, decay: float, wf_out: np.ndarray
+) -> None:
+    """Inject exponential pulse into existing waveform to simulate pileup.
+
+    Parameters
+    ----------
+    wf_in
+        the input waveform.
+    t0
+        the position of the injected waveform.
+    rt
+        the rise time of the injected waveform.
+    a
+        the amplitude of the injected waveform.
+    decay
+        the exponential decay constant of the injected waveform.
+    wf_out
+        the output waveform.
+    """
+
+    wf_out[:] = np.nan
+
+    if (
+        np.isnan(wf_in).any()
+        or np.isnan(rt)
+        or np.isnan(t0)
+        or np.isnan(a)
+        or np.isnan(decay)
+    ):
+        return
+
+    wf_out[:] = wf_in[:]
+    for t in range(len(wf_out)):
+        if t <= t0 and t <= t0 + rt:
+            wf_out[t] += a * exp((t - t0 - rt) / (rt)) * exp(-(1 / decay) * (t - t0))
+        elif t > t0 + rt:
+            wf_out[t] += a * exp(-(1 / decay) * (t - t0))
diff --git a/src/pygama/dsp/processors/saturation.py b/src/pygama/dsp/processors/saturation.py
new file mode 100644
index 000000000..09fdf3d27
--- /dev/null
+++ b/src/pygama/dsp/processors/saturation.py
@@ -0,0 +1,65 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32[:], float32[:])",
+        "void(float64[:], float64, float64[:], float64[:])",
+    ],
+    "(n),()->(),()",
+    nopython=True,
+    cache=True,
+)
+def saturation(
+    w_in: np.ndarray, bit_depth_in: int, n_lo_out: int, n_hi_out: int
+) -> None:
+    """Count the number of samples in the waveform that are saturated at the
+    minimum and maximum possible values based on the bit depth.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform
+    bit_depth_in
+        the bit depth of the analog-to-digital converter
+    n_lo_out
+        the output number of samples at the minimum
+    n_hi_out
+        the output number of samples at the maximum
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "sat_lo, sat_hi": {
+            "function": "saturation",
+            "module": "pygama.dsp.processors",
+            "args": ["waveform", "16", "sat_lo", "sat_hi"],
+            "unit": "ADC"
+        }
+    """
+    n_lo_out[0] = np.nan
+    n_hi_out[0] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(bit_depth_in):
+        return
+
+    if not np.floor(bit_depth_in) == bit_depth_in:
+        raise DSPFatal("The bit depth is not an integer")
+
+    if bit_depth_in <= 0:
+        raise DSPFatal("The bit depth is not positive")
+
+    n_lo_out[0] = 0
+    n_hi_out[0] = 0
+    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)):
+            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
new file mode 100644
index 000000000..4dd5ed2d1
--- /dev/null
+++ b/src/pygama/dsp/processors/soft_pileup_corr.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, float32[:])",
+        "void(float64[:], float64, float64, float64[:])",
+    ],
+    "(n),(),()->(n)",
+    nopython=True,
+    cache=True,
+)
+def soft_pileup_corr(
+    w_in: np.ndarray, n_in: int, tau_in: float, w_out: np.ndarray
+) -> None:
+    """Fit the baseline to an exponential with the provided time constant and
+    then subtract the best-fit function from the entire waveform.
+
+    Parameters
+    ----------
+    w_in
+        The input waveform.
+    n_in
+        The number of samples at the beginning of the waveform
+        to fit to an exponential.
+    tau_in
+        The fixed, exponential time constant.
+    w_out
+        The output waveform with the exponential subtracted.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_bl": {
+            "function": "soft_pileup_corr",
+            "module": "pygama.dsp.processors",
+            "args": ["waveform", "1000", "500*us", "wf_bl"],
+            "unit": "ADC"
+        }
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(n_in) or np.isnan(tau_in):
+        return
+
+    if not np.floor(n_in) == n_in:
+        raise DSPFatal("The number of samples is not an integer")
+
+    if n_in < 2:
+        raise DSPFatal("The number of samples is not enough for a fit")
+
+    if n_in > len(w_in):
+        raise DSPFatal("The number of samples is more than the waveform length")
+
+    s1 = 0.0
+    s2 = 0.0
+    s3 = 0.0
+    s4 = 0.0
+    s5 = 0.0
+    for i in range(0, n_in, 1):
+        s1 += 1.0
+        s2 += np.exp(-1.0 * i / tau_in)
+        s3 += np.exp(-2.0 * i / tau_in)
+        s4 += np.exp(-1.0 * i / tau_in) * w_in[i]
+        s5 += w_in[i]
+    b = (s5 - s2 * (s4 * s1 - s2 * s5) / (s3 * s1 - s2 * s2)) / s1
+    a = (s4 - b * s2) / s3
+    for i in range(0, len(w_in), 1):
+        w_out[i] = w_in[i] - (a * np.exp(-1.0 * i / tau_in) + b)
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, float32, float32[:])",
+        "void(float64[:], float64, float64, float64, float64[:])",
+    ],
+    "(n),(),(),()->(n)",
+    nopython=True,
+    cache=True,
+)
+def soft_pileup_corr_bl(
+    w_in: np.ndarray, n_in: int, tau_in: float, b_in: float, w_out: np.ndarray
+) -> None:
+    """Fit the baseline to an exponential with the provided time
+    constant and then subtract the best-fit function from the
+    entire waveform.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    n_in
+        the number of samples at the beginning of the waveform
+        to fit to an exponential.
+    tau_in
+        the fixed, exponential time constant.
+    b_in
+        the fixed, exponential constant.
+    w_out
+        the output waveform with the exponential subtracted.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_bl": {
+            "function": "soft_pileup_corr_bl",
+            "module": "pygama.dsp.processors",
+            "args": ["waveform", "1000", "500*us", "baseline", "wf_bl"],
+            "unit": "ADC"
+        }
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(n_in) or np.isnan(tau_in) or np.isnan(b_in):
+        return
+
+    if not np.floor(n_in) == n_in:
+        raise DSPFatal("The number of samples is not an integer")
+
+    if n_in < 1:
+        raise DSPFatal("The number of samples is not enough for a fit")
+
+    if n_in > len(w_in):
+        raise DSPFatal("The number of samples is more than the waveform length")
+
+    s2 = 0.0
+    s3 = 0.0
+    s4 = 0.0
+    for i in range(0, n_in, 1):
+        s2 += np.exp(-1.0 * i / tau_in)
+        s3 += np.exp(-2.0 * i / tau_in)
+        s4 += np.exp(-1.0 * i / tau_in) * w_in[i]
+    a = (s4 - b_in * s2) / s3
+    for i in range(0, len(w_in), 1):
+        w_out[i] = w_in[i] - (a * np.exp(-1.0 * i / tau_in) + b_in)
diff --git a/src/pygama/dsp/processors/time_point_thresh.py b/src/pygama/dsp/processors/time_point_thresh.py
new file mode 100644
index 000000000..67f81384b
--- /dev/null
+++ b/src/pygama/dsp/processors/time_point_thresh.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    [
+        "void(float32[:], float32, float32, float32, float32[:])",
+        "void(float64[:], float64, float64, float64, float64[:])",
+    ],
+    "(n),(),(),()->()",
+    nopython=True,
+    cache=True,
+)
+def time_point_thresh(
+    w_in: np.ndarray, a_threshold: float, t_start: int, walk_forward: int, t_out: float
+) -> None:
+    """Find the index where the waveform value crosses the threshold, walking
+    either forward or backward from the starting index.
+
+    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.
+    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, "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 np.floor(t_start) != t_start:
+        raise DSPFatal("The starting index must be an integer")
+
+    if np.floor(walk_forward) != walk_forward:
+        raise DSPFatal("The search direction must be an integer")
+
+    if int(t_start) < 0 or int(t_start) >= len(w_in):
+        raise DSPFatal("The starting index is out of range")
+
+    if int(walk_forward) == 1:
+        for i in range(int(t_start), len(w_in) - 1, 1):
+            if w_in[i] <= a_threshold < w_in[i + 1]:
+                t_out[0] = i
+                return
+    else:
+        for i in range(int(t_start), 1, -1):
+            if w_in[i - 1] < a_threshold <= w_in[i]:
+                t_out[0] = i
+                return
diff --git a/src/pygama/dsp/processors/trap_filters.py b/src/pygama/dsp/processors/trap_filters.py
new file mode 100644
index 000000000..10dfd444f
--- /dev/null
+++ b/src/pygama/dsp/processors/trap_filters.py
@@ -0,0 +1,292 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    [
+        "void(float32[:], int32, int32, float32[:])",
+        "void(float64[:], int32, int32, float64[:])",
+    ],
+    "(n),(),()->(n)",
+    nopython=True,
+    cache=True,
+)
+def trap_filter(w_in: np.ndarray, rise: int, flat: int, w_out: np.ndarray) -> None:
+    """Apply a symmetric trapezoidal filter to the waveform.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    rise
+        the number of samples averaged in the rise and fall sections.
+    flat
+        the delay between the rise and fall sections.
+    w_out
+        the filtered waveform.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_tf": {
+            "function": "trap_filter",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_pz", "10*us", "3*us", "wf_tf"],
+            "unit": "ADC"
+        }
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat):
+        return
+
+    if int(rise) < 0:
+        raise DSPFatal("The number of samples in the rise section must be positive")
+
+    if int(flat) < 0:
+        raise DSPFatal("The number of samples in the flat section must be positive")
+
+    if 2 * int(rise) + int(flat) > len(w_in):
+        raise DSPFatal("The trapezoid width is wider than the waveform")
+
+    w_out[0] = w_in[0]
+    for i in range(1, rise, 1):
+        w_out[i] = w_out[i - 1] + w_in[i]
+    for i in range(rise, rise + flat, 1):
+        w_out[i] = w_out[i - 1] + w_in[i] - w_in[i - rise]
+    for i in range(rise + flat, 2 * rise + flat, 1):
+        w_out[i] = w_out[i - 1] + w_in[i] - w_in[i - rise] - w_in[i - rise - flat]
+    for i in range(2 * rise + flat, len(w_in), 1):
+        w_out[i] = (
+            w_out[i - 1]
+            + w_in[i]
+            - w_in[i - rise]
+            - w_in[i - rise - flat]
+            + w_in[i - 2 * rise - flat]
+        )
+
+
+@guvectorize(
+    [
+        "void(float32[:], int32, int32, float32[:])",
+        "void(float64[:], int32, int32, float64[:])",
+    ],
+    "(n),(),()->(n)",
+    nopython=True,
+    cache=True,
+)
+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
+    number of samples averaged in the rise and fall sections.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    rise
+        the number of samples averaged in the rise and fall sections.
+    flat
+        the delay between the rise and fall sections.
+    w_out
+        the normalized, filtered waveform.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_tf": {
+            "function": "trap_norm",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_pz", "10*us", "3*us", "wf_tf"],
+            "unit": "ADC"
+        }
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat):
+        return
+
+    if int(rise) < 0:
+        raise DSPFatal("The number of samples in the rise section must be positive")
+
+    if int(flat) < 0:
+        raise DSPFatal("The number of samples in the flat section must be positive")
+
+    if 2 * int(rise) + int(flat) > len(w_in):
+        raise DSPFatal("The trapezoid width is wider than the waveform")
+
+    w_out[0] = w_in[0] / rise
+    for i in range(1, rise, 1):
+        w_out[i] = w_out[i - 1] + w_in[i] / rise
+    for i in range(rise, rise + flat, 1):
+        w_out[i] = w_out[i - 1] + (w_in[i] - w_in[i - rise]) / rise
+    for i in range(rise + flat, 2 * rise + flat, 1):
+        w_out[i] = (
+            w_out[i - 1] + (w_in[i] - w_in[i - rise] - w_in[i - rise - flat]) / rise
+        )
+    for i in range(2 * rise + flat, len(w_in), 1):
+        w_out[i] = (
+            w_out[i - 1]
+            + (
+                w_in[i]
+                - w_in[i - rise]
+                - w_in[i - rise - flat]
+                + w_in[i - 2 * rise - flat]
+            )
+            / rise
+        )
+
+
+@guvectorize(
+    [
+        "void(float32[:], int32, int32, int32, float32[:])",
+        "void(float64[:], int32, int32, int32, float64[:])",
+    ],
+    "(n),(),(),()->(n)",
+    nopython=True,
+    cache=True,
+)
+def asym_trap_filter(
+    w_in: np.ndarray, rise: int, flat: int, fall: int, w_out: np.ndarray
+) -> None:
+    """Apply an asymmetric trapezoidal filter to the waveform, normalized by
+    the number of samples averaged in the rise and fall sections.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    rise
+        the number of samples averaged in the rise section.
+    flat
+        the delay between the rise and fall sections.
+    fall
+        the number of samples averaged in the fall section.
+    w_out
+        the normalized, filtered waveform.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_af": {
+            "function": "asym_trap_filter",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_pz", "128*ns", "64*ns", "2*us", "wf_af"],
+            "unit": "ADC"
+        }
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat) or np.isnan(fall):
+        return
+
+    if int(rise) < 0:
+        raise DSPFatal("The number of samples in the rise section must be positive")
+
+    if int(flat) < 0:
+        raise DSPFatal("The number of samples in the flat section must be positive")
+
+    if int(fall) < 0:
+        raise DSPFatal("The number of samples in the fall section must be positive")
+
+    if int(rise) + int(flat) + int(fall) > len(w_in):
+        raise DSPFatal("The trapezoid width is wider than the waveform")
+
+    w_out[0] = w_in[0] / rise
+    for i in range(1, rise, 1):
+        w_out[i] = w_out[i - 1] + w_in[i] / rise
+    for i in range(rise, rise + flat, 1):
+        w_out[i] = w_out[i - 1] + (w_in[i] - w_in[i - rise]) / rise
+    for i in range(rise + flat, rise + flat + fall, 1):
+        w_out[i] = (
+            w_out[i - 1]
+            + (w_in[i] - w_in[i - rise]) / rise
+            - w_in[i - rise - flat] / fall
+        )
+    for i in range(rise + flat + fall, len(w_in), 1):
+        w_out[i] = (
+            w_out[i - 1]
+            + (w_in[i] - w_in[i - rise]) / rise
+            - (w_in[i - rise - flat] - w_in[i - rise - flat - fall]) / fall
+        )
+
+
+@guvectorize(
+    [
+        "void(float32[:], int32, int32, float32, float32[:])",
+        "void(float64[:], int32, int32, float64, float64[:])",
+    ],
+    "(n),(),(),()->()",
+    nopython=True,
+    cache=True,
+)
+def trap_pickoff(
+    w_in: np.ndarray, rise: int, flat: int, t_pickoff: float, a_out: float
+) -> None:
+    """Pick off the value at the provided index of a symmetric trapezoidal
+    filter to the input waveform, normalized by the number of samples averaged
+    in the rise and fall sections.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    rise
+        the number of samples averaged in the rise and fall sections.
+    flat
+        the delay between the rise and fall sections.
+    t_pickoff
+        the waveform index to pick off.
+    a_out
+        the output pick-off value of the filtered waveform.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "ct_corr": {
+            "function": "trap_pickoff",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_pz", "1.5*us", 0, "tp_0", "ct_corr"],
+            "unit": "ADC"
+        }
+    """
+    a_out[0] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat) or np.isnan(t_pickoff):
+        return
+
+    if np.floor(t_pickoff) != t_pickoff:
+        raise DSPFatal("The pick-off index must be an integer")
+
+    if int(rise) < 0:
+        raise DSPFatal("The number of samples in the rise section must be positive")
+
+    if int(flat) < 0:
+        raise DSPFatal("The number of samples in the flat section must be positive")
+
+    if 2 * int(rise) + int(flat) > len(w_in):
+        raise DSPFatal("The trapezoid width is wider than the waveform")
+
+    i_1 = 0.0
+    i_2 = 0.0
+    start_time = int(t_pickoff + 1)
+
+    if not len(w_in) >= start_time >= 2 * rise + flat:
+        return
+
+    for i in range(start_time - rise, start_time, 1):
+        i_1 += w_in[i]
+    for i in range(start_time - 2 * rise - flat, start_time - rise - flat, 1):
+        i_2 += w_in[i]
+    a_out[0] = (i_1 - i_2) / rise
diff --git a/src/pygama/dsp/processors/upsampler.py b/src/pygama/dsp/processors/upsampler.py
new file mode 100644
index 000000000..d42425189
--- /dev/null
+++ b/src/pygama/dsp/processors/upsampler.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
+    "(n),(),(m)",
+    nopython=True,
+    cache=True,
+)
+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.
+
+    Parameters
+    ----------
+    w_in
+        waveform to upsample.
+    upsample
+        number of samples to increase each sample to.
+    w_out
+        output array for upsampled waveform.
+    """
+
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any():
+        return
+
+    if not (upsample > 0):
+        raise DSPFatal("Upsample must be greater than 0")
+
+    for t_in in range(0, len(w_in)):
+        t_out = int(t_in * upsample - np.floor(upsample / 2))
+        for _ in range(0, int(upsample)):
+            if (t_out >= 0) & (t_out < len(w_out)):
+                w_out[t_out] = w_in[t_in]
+            t_out += 1
diff --git a/src/pygama/dsp/processors/wiener_filter.py b/src/pygama/dsp/processors/wiener_filter.py
new file mode 100644
index 000000000..27e5d36c4
--- /dev/null
+++ b/src/pygama/dsp/processors/wiener_filter.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+import pygama.lgdo.lh5_store as lh5
+from pygama.dsp.errors import DSPFatal
+
+
+def wiener_filter(file_name_array: list[str]) -> np.ndarray:
+    """Apply a Wiener filter to the waveform.
+
+    Note
+    ----
+    The convolution is performed in the frequency domain.  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 input
+    must be the Fourier transform of the waveform. The output is the filtered
+    waveform in the frequency domain.
+
+    Parameters
+    ----------
+    file_name_array
+        Array with path to an HDF5 file containing the time domain version of
+        the superpulse in one column and noise waveform in another, the
+        superpulse HDF5 group must be titled ``spms/processed/superpulse`` and
+        the noise waveform must be called ``spms/processed/noise_wf``.
+
+    JSON Configuration Example
+    --------------------------
+
+    .. code-block :: json
+
+        "wf_wiener": {
+            "function": "wiener_filter",
+            "module": "pygama.dsp.processors",
+            "args": ["wf_bl_fft", "wf_wiener(2000,f)"],
+            "unit": "dB",
+            "init_args": ["/path/to/file/wiener.lh5"]
+        }
+    """
+
+    sto = lh5.LH5Store()
+
+    # Check that the file is valid and the data is in the correct format
+
+    try:
+        file_name_array[0]
+    except Exception:
+        raise DSPFatal("init_args must be an array with the filename")
+
+    file_name = file_name_array[0]
+
+    try:
+        f = sto.gimme_file(file_name, "r")
+    except Exception:
+        raise DSPFatal("File must be a valid lh5 file")
+
+    if "spms/processed/superpulse" not in f:
+        raise DSPFatal("lh5 file must have 'spms/processed/superpulse' as a group")
+
+    if "spms/processed/noise_wf" not in f:
+        raise DSPFatal("lh5 file must have 'spms/processed/noise_wf' as a group")
+
+    # Read in the data
+
+    superpulse, _ = sto.read_object("spms/processed/superpulse", file_name)
+    superpulse = superpulse.nda
+
+    noise_wf, _ = sto.read_object("spms/processed/noise_wf", file_name)
+    noise_wf = noise_wf.nda
+
+    # Now check that the data are valid
+
+    if len(superpulse) <= 0:
+        raise DSPFatal("The length of the filter must be positive")
+
+    if len(superpulse) != len(noise_wf):
+        raise DSPFatal(
+            "The length of the superpulse must be equal to the length of the noise waveform"
+        )
+
+    if np.argmax(superpulse) <= 0 or np.argmax(superpulse) > len(superpulse):
+        raise DSPFatal(
+            "The index of the maximum of the superpulse must occur within the waveform"
+        )
+
+    # Transform these to the frequency domain to eventually create the wiener filter
+
+    fft_superpulse = np.fft.fft(superpulse)
+    fft_noise_wf = np.fft.fft(noise_wf)
+
+    # Create the point spread function for the detector's response
+
+    def psf(superpulse, fft_superpulse):
+
+        delta = np.zeros_like(superpulse)
+        arg_max = np.argmax(superpulse)
+        delta[arg_max] = np.amax(superpulse)
+
+        return fft_superpulse / np.fft.fft(delta)
+
+    # Now create the wiener filter in the frequency domain
+
+    fft_psf = psf(superpulse, fft_superpulse)
+    psd_noise_wf = fft_noise_wf * np.conj(fft_noise_wf)
+    psd_superpulse = fft_superpulse * np.conj(fft_superpulse)
+
+    w_filter = (np.conj(fft_psf)) / (
+        (fft_psf * np.conj(fft_psf)) + (psd_noise_wf / psd_superpulse)
+    )
+
+    # Create a factory function that performs the convolution with the wiener filter, the output is still in the frequency domain
+
+    @guvectorize(
+        ["void(complex64[:], complex64[:])", "void(complex128[:], complex128[:])"],
+        "(n)->(n)",
+        forceobj=True,
+    )
+    def wiener_out(fft_w_in: np.ndarray, fft_w_out: np.ndarray) -> None:
+        """
+        Parameters
+        ----------
+        fft_w_in
+            the Fourier transformed input waveform.
+        fft_w_out
+            the filtered waveform, in the frequency domain.
+        """
+        fft_w_out[:] = np.nan
+
+        if np.isnan(fft_w_in).any():
+            return
+
+        if len(w_filter) != len(fft_w_in):
+            raise DSPFatal("The filter is not the same length of the input waveform")
+
+        fft_w_out[:] = fft_w_in * w_filter
+
+    return wiener_out
diff --git a/src/pygama/dsp/processors/windower.py b/src/pygama/dsp/processors/windower.py
new file mode 100644
index 000000000..1a26aa61c
--- /dev/null
+++ b/src/pygama/dsp/processors/windower.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+import numpy as np
+from numba import guvectorize
+
+from pygama.dsp.errors import DSPFatal
+
+
+@guvectorize(
+    ["void(float32[:], float32, float32[:])", "void(float64[:], float64, float64[:])"],
+    "(n),(),(m)",
+    nopython=True,
+    cache=True,
+)
+def windower(w_in: np.ndarray, t0_in: int, w_out: np.ndarray) -> None:
+    """Return a shorter sample of the waveform, starting at the
+    specified index.
+
+    Note
+    ----
+    The length of the output waveform is determined by the length of `w_out`
+    rather than an input parameter.  If the the length of `w_out` plus `t0_in`
+    extends past the end of `w_in` or if `t0_in` is negative, remaining values
+    are padded with :any:`numpy.nan`.
+
+    Parameters
+    ----------
+    w_in
+        the input waveform.
+    t0_in
+        the starting index of the window.
+    w_out
+        the windowed waveform.
+    """
+    w_out[:] = np.nan
+
+    if np.isnan(w_in).any() or np.isnan(t0_in):
+        return
+
+    if len(w_out) >= len(w_in):
+        raise DSPFatal("The windowed waveform must be smaller than the input waveform")
+
+    beg = min(int(t0_in), len(w_in))
+    end = max(beg + len(w_out), 0)
+    if beg < 0:
+        w_out[: len(w_out) - end] = np.nan
+        w_out[len(w_out) - end :] = w_in[:end]
+    elif end < len(w_in):
+        w_out[:] = w_in[beg:end]
+    else:
+        w_out[: len(w_in) - beg] = w_in[beg : len(w_in)]
+        w_out[len(w_in) - beg :] = np.nan
diff --git a/src/pygama/evt/__init__.py b/src/pygama/evt/__init__.py
index 4e261f2d3..76d4f607e 100644
--- a/src/pygama/evt/__init__.py
+++ b/src/pygama/evt/__init__.py
@@ -2,5 +2,7 @@
 Subpackage description
 """
 
-from .build_tcm import build_tcm
-from .tcm import generate_tcm_cols
+from pygama.evt.build_tcm import build_tcm
+from pygama.evt.tcm import generate_tcm_cols
+
+__all__ = ["build_tcm", "generate_tcm_cols"]
diff --git a/src/pygama/evt/build_tcm.py b/src/pygama/evt/build_tcm.py
index 7bdb5957c..181554461 100644
--- a/src/pygama/evt/build_tcm.py
+++ b/src/pygama/evt/build_tcm.py
@@ -6,9 +6,16 @@
 import pygama.lgdo as lgdo
 
 
-def build_tcm(input_tables:list, coin_col:str, hash_func:str=r'\d+',
-              coin_window:float=0, window_ref:str='last',
-              out_file:str=None, out_name:str='tcm', wo_mode:str='write_safe'):
+def build_tcm(
+    input_tables: list,
+    coin_col: str,
+    hash_func: str = r"\d+",
+    coin_window: float = 0,
+    window_ref: str = "last",
+    out_file: str = None,
+    out_name: str = "tcm",
+    wo_mode: str = "write_safe",
+):
     """
     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
@@ -59,17 +66,26 @@ def build_tcm(input_tables:list, coin_col:str, hash_func:str=r'\d+',
             if hash_func is not None:
                 if isinstance(hash_func, str):
                     array_id = int(re.search(hash_func, table).group())
-                else: raise NotImplementedError(f"hash_func of type {type(hash_func).__name__}")
-            else: array_id = len(all_tables)-1
-            table = table + '/' + coin_col
+                else:
+                    raise NotImplementedError(
+                        f"hash_func of type {type(hash_func).__name__}"
+                    )
+            else:
+                array_id = len(all_tables) - 1
+            table = table + "/" + coin_col
             coin_data.append(store.read_object(table, filename)[0].nda)
             array_ids.append(array_id)
 
-    tcm_cols = ptcm.generate_tcm_cols(coin_data, coin_window=coin_window,
-                                      window_ref=window_ref, array_ids=array_ids)
+    tcm_cols = ptcm.generate_tcm_cols(
+        coin_data, coin_window=coin_window, window_ref=window_ref, array_ids=array_ids
+    )
 
-    for key in tcm_cols: tcm_cols[key] = lgdo.Array(nda=tcm_cols[key])
-    tcm = lgdo.Table(col_dict=tcm_cols, attrs={ 'tables':str(all_tables), 'hash_func':str(hash_func) })
+    for key in tcm_cols:
+        tcm_cols[key] = lgdo.Array(nda=tcm_cols[key])
+    tcm = lgdo.Struct(
+        obj_dict=tcm_cols,
+        attrs={"tables": str(all_tables), "hash_func": str(hash_func)},
+    )
 
     if out_file is not None:
         store.write_object(tcm, out_name, out_file, wo_mode=wo_mode)
diff --git a/src/pygama/evt/tcm.py b/src/pygama/evt/tcm.py
index 6ebe1f170..b85b7769e 100644
--- a/src/pygama/evt/tcm.py
+++ b/src/pygama/evt/tcm.py
@@ -6,8 +6,14 @@
 # later on we might want a tcm class, or interface / inherit from an entry list
 # class. For now we just need the key clustering functionality
 
-def generate_tcm_cols(coin_data:list, coin_window:float=0, window_ref:str='last',
-                      array_ids:list=None, array_idxs:list=None):
+
+def generate_tcm_cols(
+    coin_data: list,
+    coin_window: float = 0,
+    window_ref: str = "last",
+    array_ids: list = None,
+    array_idxs: list = None,
+):
     """
     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
@@ -60,25 +66,34 @@ def generate_tcm_cols(coin_data:list, coin_window:float=0, window_ref:str='last'
         array = np.array(array)
         array_id = array_ids[ii] if array_ids is not None else ii
         array_id = np.full_like(array, array_id)
-        col_dict = {'array_id':array_id, 'coin_data':array}
-        if array_idxs is not None: col_dict['array_idx'] = array_idxs[ii]
+        col_dict = {"array_id": array_id, "coin_data": array}
+        if array_idxs is not None:
+            col_dict["array_idx"] = array_idxs[ii]
         dfs.append(pd.DataFrame(col_dict, copy=False))
 
     # concat and sort
-    tcm = pd.concat(dfs).sort_values(['coin_data', 'array_id'])
+    tcm = pd.concat(dfs).sort_values(["coin_data", "array_id"])
 
     # compute coin_data diffs
-    tcm['dcoin'] = tcm['coin_data'].diff()
+    tcm["dcoin"] = tcm["coin_data"].diff()
 
     # window into coincidences
-    if window_ref == 'last':
+    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()
+        tcm["coin_idx"] = (tcm.dcoin > coin_window).cumsum()
     else:
-        raise NotImplementedError(f'window_ref {window_ref}')
+        raise NotImplementedError(f"window_ref {window_ref}")
 
     # now build the outputs
-    coin_idx = tcm.coin_idx.to_numpy()
+    cumulative_length = np.where(tcm.coin_idx.diff().to_numpy() != 0)[0]
+    cumulative_length[:-1] = cumulative_length[1:]
+    cumulative_length[-1] = len(tcm.coin_idx)
     array_id = tcm.array_id.to_numpy()
-    array_idx = tcm.array_idx.to_numpy() if 'array_idx' in tcm else tcm.index.to_numpy() # beautiful!
-    return { 'coin_idx':coin_idx, 'array_id':array_id, 'array_idx':array_idx }
+    array_idx = (
+        tcm.array_idx.to_numpy() if "array_idx" in tcm else tcm.index.to_numpy()
+    )  # beautiful!
+    return {
+        "cumulative_length": cumulative_length,
+        "array_id": array_id,
+        "array_idx": array_idx,
+    }
diff --git a/src/pygama/flow/__init__.py b/src/pygama/flow/__init__.py
index a951ffb6d..31896c850 100644
--- a/src/pygama/flow/__init__.py
+++ b/src/pygama/flow/__init__.py
@@ -3,3 +3,5 @@
 """
 
 from .datagroup import DataGroup
+
+__all__ = ["DataGroup"]
diff --git a/src/pygama/hit/build_hit.py b/src/pygama/hit/build_hit.py
index a82d1b41e..fdffa2a0f 100644
--- a/src/pygama/hit/build_hit.py
+++ b/src/pygama/hit/build_hit.py
@@ -1,1 +1,1 @@
-#placeholder
+# placeholder
diff --git a/src/pygama/lgdo/__init__.py b/src/pygama/lgdo/__init__.py
index 2798f8f04..426188232 100644
--- a/src/pygama/lgdo/__init__.py
+++ b/src/pygama/lgdo/__init__.py
@@ -36,5 +36,30 @@
 from pygama.lgdo.scalar import Scalar
 from pygama.lgdo.struct import Struct
 from pygama.lgdo.table import Table
-from pygama.lgdo.vectorofvectors import VectorOfVectors
+from pygama.lgdo.vectorofvectors import (
+    VectorOfVectors,
+    build_cl,
+    explode,
+    explode_arrays,
+    explode_cl,
+)
 from pygama.lgdo.waveform_table import WaveformTable
+
+__all__ = [
+    "Array",
+    "ArrayOfEqualSizedArrays",
+    "FixedSizeArray",
+    "Scalar",
+    "Struct",
+    "Table",
+    "VectorOfVectors",
+    "WaveformTable",
+    "LH5Store",
+    "load_dfs",
+    "load_nda",
+    "ls",
+    "build_cl",
+    "explode",
+    "explode_arrays",
+    "explode_cl",
+]
diff --git a/src/pygama/lgdo/array.py b/src/pygama/lgdo/array.py
index 16777698b..e4c5ccc73 100644
--- a/src/pygama/lgdo/array.py
+++ b/src/pygama/lgdo/array.py
@@ -31,9 +31,14 @@ class Array:
 
     """
 
-    def __init__(self, nda: np.ndarray = None, shape: tuple[int, ...] = (),
-                 dtype: np.dtype = None, fill_val: float | int = None,
-                 attrs: dict[str, Any] = None) -> None:
+    def __init__(
+        self,
+        nda: np.ndarray = None,
+        shape: tuple[int, ...] = (),
+        dtype: np.dtype = None,
+        fill_val: float | int = None,
+        attrs: dict[str, Any] = None,
+    ) -> None:
         """
         Parameters
         ----------
@@ -56,33 +61,38 @@ def __init__(self, nda: np.ndarray = None, shape: tuple[int, ...] = (),
             A set of user attributes to be carried along with this LGDO.
         """
         if nda is None:
-            if fill_val is None: nda = np.empty(shape, dtype=dtype)
-            elif fill_val == 0: nda = np.zeros(shape, dtype=dtype)
-            else: nda = np.full(shape, fill_val, dtype=dtype)
+            if fill_val is None:
+                nda = np.empty(shape, dtype=dtype)
+            elif fill_val == 0:
+                nda = np.zeros(shape, dtype=dtype)
+            else:
+                nda = np.full(shape, fill_val, dtype=dtype)
         self.nda = nda
         self.dtype = self.nda.dtype
 
         self.attrs = {} if attrs is None else dict(attrs)
 
-        if 'datatype' in self.attrs:
-            if self.attrs['datatype'] != self.form_datatype():
-                raise RuntimeError('datatype does not match nda! '
-                                   f'datatype: {self.attrs["datatype"]} '
-                                   f'form_datatype(): {self.form_datatype()} '
-                                   f'dtype: {self.dtype}')
+        if "datatype" in self.attrs:
+            if self.attrs["datatype"] != self.form_datatype():
+                raise RuntimeError(
+                    "datatype does not match nda! "
+                    f'datatype: {self.attrs["datatype"]} '
+                    f"form_datatype(): {self.form_datatype()} "
+                    f"dtype: {self.dtype}"
+                )
         else:
-            self.attrs['datatype'] = self.form_datatype()
+            self.attrs["datatype"] = self.form_datatype()
 
     def datatype_name(self) -> str:
         """The name for this LGDO's datatype attribute."""
-        return 'array'
+        return "array"
 
     def form_datatype(self) -> str:
         """Return this LGDO's datatype attribute string."""
         dt = self.datatype_name()
-        nD = str(len(self.nda.shape))
+        nd = str(len(self.nda.shape))
         et = get_element_type(self)
-        return dt + '<' + nD + '>{' + et + '}'
+        return dt + "<" + nd + ">{" + et + "}"
 
     def __len__(self) -> int:
         return len(self.nda)
@@ -94,10 +104,10 @@ def resize(self, new_size: int) -> None:
 
     def __str__(self) -> str:
         tmp_attrs = self.attrs.copy()
-        datatype = tmp_attrs.pop('datatype')
+        datatype = tmp_attrs.pop("datatype")
         string = datatype + " = " + str(self.nda)
         if len(tmp_attrs) > 0:
-            string += '\n attrs = ' + str(tmp_attrs)
+            string += "\n attrs = " + str(tmp_attrs)
         return string
 
     def __repr__(self) -> str:
diff --git a/src/pygama/lgdo/arrayofequalsizedarrays.py b/src/pygama/lgdo/arrayofequalsizedarrays.py
index c9d0da03d..3cab5af72 100644
--- a/src/pygama/lgdo/arrayofequalsizedarrays.py
+++ b/src/pygama/lgdo/arrayofequalsizedarrays.py
@@ -19,9 +19,15 @@ class ArrayOfEqualSizedArrays(Array):
     to application. Canonical example: array of same-length waveforms.
     """
 
-    def __init__(self, dims: tuple[int, ...] = None, nda: numpy.ndarray = None,
-                 shape: tuple[int, ...] = (), dtype: numpy.dtype = None,
-                 fill_val: int | float = None, attrs: dict[str, Any] = None) -> None:
+    def __init__(
+        self,
+        dims: tuple[int, ...] = None,
+        nda: numpy.ndarray = None,
+        shape: tuple[int, ...] = (),
+        dtype: numpy.dtype = None,
+        fill_val: int | float = None,
+        attrs: dict[str, Any] = None,
+    ) -> None:
         """
         Parameters
         ----------
@@ -55,20 +61,22 @@ def __init__(self, dims: tuple[int, ...] = None, nda: numpy.ndarray = None,
         :class:`.Array`
         """
         self.dims = dims
-        super().__init__(nda=nda, shape=shape, dtype=dtype, fill_val=fill_val, attrs=attrs)
+        super().__init__(
+            nda=nda, shape=shape, dtype=dtype, fill_val=fill_val, attrs=attrs
+        )
 
     def datatype_name(self) -> str:
         """Returns the name for this LGDO's datatype attribute."""
-        return 'array_of_equalsized_arrays'
+        return "array_of_equalsized_arrays"
 
     def form_datatype(self) -> str:
         """Return this LGDO's datatype attribute string."""
         dt = self.datatype_name()
-        nD = str(len(self.nda.shape))
+        nd = str(len(self.nda.shape))
         if self.dims is not None:
-            nD = ','.join([str(i) for i in self.dims])
+            nd = ",".join([str(i) for i in self.dims])
         et = get_element_type(self)
-        return dt + '<' + nD + '>{' + et + '}'
+        return dt + "<" + nd + ">{" + et + "}"
 
     def __len__(self) -> int:
         return len(self.nda)
diff --git a/src/pygama/lgdo/fixedsizearray.py b/src/pygama/lgdo/fixedsizearray.py
index f820394ba..9a272fe63 100644
--- a/src/pygama/lgdo/fixedsizearray.py
+++ b/src/pygama/lgdo/fixedsizearray.py
@@ -22,16 +22,23 @@ class FixedSizeArray(Array):
     just an alias for :class:`~.Array`, but keeps track of the datatype name.
     """
 
-    def __init__(self, nda: numpy.ndarray = None, shape: tuple[int, ...] = (),
-                 dtype: numpy.dtype = None, fill_val: int | float = None,
-                 attrs: dict[str, Any] = None) -> None:
+    def __init__(
+        self,
+        nda: numpy.ndarray = None,
+        shape: tuple[int, ...] = (),
+        dtype: numpy.dtype = None,
+        fill_val: int | float = None,
+        attrs: dict[str, Any] = None,
+    ) -> None:
         """
         See Also
         --------
         :class:`.Array`
         """
-        super().__init__(nda=nda, shape=shape, dtype=dtype, fill_val=fill_val, attrs=attrs)
+        super().__init__(
+            nda=nda, shape=shape, dtype=dtype, fill_val=fill_val, attrs=attrs
+        )
 
     def datatype_name(self) -> str:
         """The name for this object's HDF5 datatype attribute."""
-        return 'fixedsize_array'
+        return "fixedsize_array"
diff --git a/src/pygama/lgdo/lgdo_utils.py b/src/pygama/lgdo/lgdo_utils.py
index 023149f18..669b6e300 100644
--- a/src/pygama/lgdo/lgdo_utils.py
+++ b/src/pygama/lgdo/lgdo_utils.py
@@ -31,25 +31,27 @@ def get_element_type(obj: object) -> str:
 
     # special handling for strings
     if isinstance(obj, str):
-        return 'string'
+        return "string"
 
     # the rest use dtypes
-    dt = obj.dtype if hasattr(obj, 'dtype') else np.dtype(type(obj))
+    dt = obj.dtype if hasattr(obj, "dtype") else np.dtype(type(obj))
     kind = dt.kind
 
-    if kind == 'b':
-        return 'bool'
-    if kind == 'V':
-        return 'blob'
-    if kind in ['i', 'u', 'f']:
-        return 'real'
-    if kind == 'c':
-        return 'complex'
-    if kind in ['S', 'U']:
-        return 'string'
+    if kind == "b":
+        return "bool"
+    if kind == "V":
+        return "blob"
+    if kind in ["i", "u", "f"]:
+        return "real"
+    if kind == "c":
+        return "complex"
+    if kind in ["S", "U"]:
+        return "string"
 
     # couldn't figure it out
-    raise ValueError('cannot determine lgdo element_type for object of type', type(obj).__name__)
+    raise ValueError(
+        "cannot determine lgdo element_type for object of type", type(obj).__name__
+    )
 
 
 def parse_datatype(datatype: str) -> tuple[str, tuple[int, ...], str | list[str]]:
@@ -71,15 +73,16 @@ def parse_datatype(datatype: str) -> tuple[str, tuple[int, ...], str | list[str]
         numeric objects, the element type for struct-like  objects, the list of
         fields in the struct.
     """
-    if '{' not in datatype:
-        return 'scalar', None, datatype
+    if "{" not in datatype:
+        return "scalar", None, datatype
 
     # for other datatypes, need to parse the datatype string
     from parse import parse
-    datatype, element_description = parse('{}{{{}}}', datatype)
-    if datatype.endswith('>'):
-        datatype, dims = parse('{}<{}>', datatype)
-        dims = [int(i) for i in dims.split(',')]
+
+    datatype, element_description = parse("{}{{{}}}", datatype)
+    if datatype.endswith(">"):
+        datatype, dims = parse("{}<{}>", datatype)
+        dims = [int(i) for i in dims.split(",")]
         return datatype, tuple(dims), element_description
     else:
-        return datatype, None, element_description.split(',')
+        return datatype, None, element_description.split(",")
diff --git a/src/pygama/lgdo/lh5_store.py b/src/pygama/lgdo/lh5_store.py
index f4d0b9b0b..369cf683e 100644
--- a/src/pygama/lgdo/lh5_store.py
+++ b/src/pygama/lgdo/lh5_store.py
@@ -46,7 +46,7 @@ class LH5Store:
     pygama.lgdo.waveform_table.WaveformTable
     """
 
-    def __init__(self, base_path: str = '', keep_open: bool = False) -> None:
+    def __init__(self, base_path: str = "", keep_open: bool = False) -> None:
         """
         Parameters
         ----------
@@ -60,7 +60,7 @@ class attributes.
         self.keep_open = keep_open
         self.files = {}
 
-    def gimme_file(self, lh5_file: str | h5py.File, mode: str = 'r') -> h5py.File:
+    def gimme_file(self, lh5_file: str | h5py.File, mode: str = "r") -> h5py.File:
         """Returns a :mod:`h5py` file object from the store or creates a new one.
 
         Parameters
@@ -74,28 +74,31 @@ def gimme_file(self, lh5_file: str | h5py.File, mode: str = 'r') -> h5py.File:
             return lh5_file
         if lh5_file in self.files.keys():
             return self.files[lh5_file]
-        if self.base_path != '':
+        if self.base_path != "":
             full_path = os.path.join(self.base_path, lh5_file)
         else:
             full_path = lh5_file
-        if mode != 'r':
+        if mode != "r":
             directory = os.path.dirname(full_path)
-            if directory != '' and not os.path.exists(directory):
-                log.debug(f'making path {directory}')
+            if directory != "" and not os.path.exists(directory):
+                log.debug(f"making path {directory}")
                 os.makedirs(directory)
-        if mode == 'r' and not os.path.exists(full_path):
-            raise FileNotFoundError(f'file {full_path} not found')
-        if mode != 'r' and os.path.exists(full_path):
+        if mode == "r" and not os.path.exists(full_path):
+            raise FileNotFoundError(f"file {full_path} not found")
+        if mode != "r" and os.path.exists(full_path):
             log.debug(f"opening existing file {full_path} in mode '{mode}'")
         h5f = h5py.File(full_path, mode)
         if self.keep_open:
             self.files[lh5_file] = h5f
         return h5f
 
-    def gimme_group(self, group: str,
-                    base_group: h5py.Group,
-                    grp_attrs: dict[str, Any] = None,
-                    overwrite: bool = False) -> h5py.Group:
+    def gimme_group(
+        self,
+        group: str,
+        base_group: h5py.Group,
+        grp_attrs: dict[str, Any] = None,
+        overwrite: bool = False,
+    ) -> h5py.Group:
         """
         Returns an existing :class:`h5py` group from a base group or creates a
         new one. Can also set (or replace) group attributes.
@@ -113,40 +116,52 @@ def gimme_group(self, group: str,
             ``None``.
         """
         if not isinstance(group, h5py.Group):
-            if group in base_group: group = base_group[group]
+            if group in base_group:
+                group = base_group[group]
             else:
                 group = base_group.create_group(group)
-                if grp_attrs is not None: group.attrs.update(grp_attrs)
+                if grp_attrs is not None:
+                    group.attrs.update(grp_attrs)
                 return group
-        if grp_attrs is not None and len(set(grp_attrs.items()) ^ set(group.attrs.items())) > 0:
+        if (
+            grp_attrs is not None
+            and len(set(grp_attrs.items()) ^ set(group.attrs.items())) > 0
+        ):
             if not overwrite:
-                raise RuntimeError('grp_attrs != group.attrs but overwrite not set')
+                raise RuntimeError("grp_attrs != group.attrs but overwrite not set")
             else:
-                log.debug(f'overwriting {group}.attrs...')
-                for key in group.attrs.keys(): group.attrs.pop(key)
+                log.debug(f"overwriting {group}.attrs...")
+                for key in group.attrs.keys():
+                    group.attrs.pop(key)
                 group.attrs.update(grp_attrs)
         return group
 
-
-    def get_buffer(self, name: str,
-                   lh5_file: str | h5py.File | list[str | h5py.File],
-                   size: int = None,
-                   field_mask: dict[str, bool] | list[str] | tuple[str] = None) -> LGDO:
+    def get_buffer(
+        self,
+        name: str,
+        lh5_file: str | h5py.File | list[str | h5py.File],
+        size: int = None,
+        field_mask: dict[str, bool] | list[str] | tuple[str] = None,
+    ) -> LGDO:
         """Returns an LH5 object appropriate for use as a pre-allocated buffer
         in a read loop. Sets size to `size` if object has a size.
         """
         obj, n_rows = self.read_object(name, lh5_file, n_rows=0, field_mask=field_mask)
-        if hasattr(obj, 'resize') and size is not None: obj.resize(new_size=size)
+        if hasattr(obj, "resize") and size is not None:
+            obj.resize(new_size=size)
         return obj
 
-    def read_object(self, name: str,
-                    lh5_file: str | h5py.File | list[str | h5py.File],
-                    start_row: int = 0,
-                    n_rows: int = sys.maxsize,
-                    idx: np.ndarray | list | tuple | list[np.ndarray | list | tuple] = None,
-                    field_mask: dict[str, bool] | list[str] | tuple[str] = None,
-                    obj_buf: LGDO = None,
-                    obj_buf_start: int = 0) -> tuple[LGDO, int]:
+    def read_object(
+        self,
+        name: str,
+        lh5_file: str | h5py.File | list[str | h5py.File],
+        start_row: int = 0,
+        n_rows: int = sys.maxsize,
+        idx: np.ndarray | list | tuple | list[np.ndarray | list | tuple] = None,
+        field_mask: dict[str, bool] | list[str] | tuple[str] = None,
+        obj_buf: LGDO = None,
+        obj_buf_start: int = 0,
+    ) -> tuple[LGDO, int]:
         """Read LH5 object data from a file.
 
         Parameters
@@ -211,7 +226,8 @@ def read_object(self, name: str,
                     idx_i = idx[i]
                 elif idx is not None:
                     # make idx a proper tuple if it's not one already
-                    if not (isinstance(idx, tuple) and len(idx) == 1): idx = (idx,)
+                    if not (isinstance(idx, tuple) and len(idx) == 1):
+                        idx = (idx,)
                     # idx is a long continuous array
                     n_rows_i = self.read_n_rows(name, h5f)
                     # find the length of the subset of idx that contains indices
@@ -219,19 +235,22 @@ def read_object(self, name: str,
                     n_rows_to_read_i = bisect_left(idx[0], n_rows_i)
                     # now split idx into idx_i and the remainder
                     idx_i = (idx[0][:n_rows_to_read_i],)
-                    idx = (idx[0][n_rows_to_read_i:]-n_rows_i,)
-                else: idx_i = None
-                n_rows_i = n_rows-n_rows_read
-                obj_buf, n_rows_read_i = self.read_object(name,
-                                                          lh5_file[i],
-                                                          start_row=start_row,
-                                                          n_rows=n_rows_i,
-                                                          idx=idx_i,
-                                                          field_mask=field_mask,
-                                                          obj_buf=obj_buf,
-                                                          obj_buf_start=obj_buf_start)
+                    idx = (idx[0][n_rows_to_read_i:] - n_rows_i,)
+                else:
+                    idx_i = None
+                n_rows_i = n_rows - n_rows_read
+                obj_buf, n_rows_read_i = self.read_object(
+                    name,
+                    lh5_file[i],
+                    start_row=start_row,
+                    n_rows=n_rows_i,
+                    idx=idx_i,
+                    field_mask=field_mask,
+                    obj_buf=obj_buf,
+                    obj_buf_start=obj_buf_start,
+                )
                 n_rows_read += n_rows_read_i
-                if n_rows_read >= n_rows or obj_buf == None:
+                if n_rows_read >= n_rows or obj_buf is None:
                     return obj_buf, n_rows_read
                 start_row = 0
                 obj_buf_start += n_rows_read_i
@@ -241,51 +260,58 @@ def read_object(self, name: str,
         log.debug(f"reading '{name}' from {lh5_file}")
 
         # get the file from the store
-        h5f = self.gimme_file(lh5_file, 'r')
+        h5f = self.gimme_file(lh5_file, "r")
         if not h5f or name not in h5f:
             raise KeyError(f"'{name}' not in {lh5_file}")
 
         # make idx a proper tuple if it's not one already
         if not (isinstance(idx, tuple) and len(idx) == 1):
-            if idx is not None: idx = (idx,)
+            if idx is not None:
+                idx = (idx,)
 
         # get the object's datatype
-        if 'datatype' not in h5f[name].attrs:
-            raise RuntimeError(f"'{name}' in file {lh5_file} is missing the datatype attribute")
+        if "datatype" not in h5f[name].attrs:
+            raise RuntimeError(
+                f"'{name}' in file {lh5_file} is missing the datatype attribute"
+            )
 
-        datatype = h5f[name].attrs['datatype']
+        datatype = h5f[name].attrs["datatype"]
         datatype, shape, elements = parse_datatype(datatype)
 
         # check field_mask and make it a default dict
-        if datatype == 'struct' or datatype == 'table':
-            if field_mask is None: field_mask = defaultdict(lambda : True)
+        if datatype == "struct" or datatype == "table":
+            if field_mask is None:
+                field_mask = defaultdict(lambda: True)
             elif isinstance(field_mask, dict):
                 default = True
                 if len(field_mask) > 0:
                     default = not field_mask[field_mask.keys[0]]
-                field_mask = defaultdict(lambda : default, field_mask)
+                field_mask = defaultdict(lambda: default, field_mask)
             elif isinstance(field_mask, (list, tuple)):
-                field_mask = defaultdict(lambda : False, { field : True for field in field_mask} )
+                field_mask = defaultdict(
+                    lambda: False, {field: True for field in field_mask}
+                )
             elif not isinstance(field_mask, defaultdict):
-                raise RuntimeError('bad field_mask of type', type(field_mask).__name__)
+                raise RuntimeError("bad field_mask of type", type(field_mask).__name__)
         elif field_mask is not None:
-            raise RuntimeError(f'datatype {datatype} does not accept a field_mask')
+            raise RuntimeError(f"datatype {datatype} does not accept a field_mask")
 
         # Scalar
         # scalars are dim-0 datasets
-        if datatype == 'scalar':
+        if datatype == "scalar":
             value = h5f[name][()]
-            if elements == 'bool': value = np.bool(value)
+            if elements == "bool":
+                value = np.bool(value)
             if obj_buf is not None:
                 obj_buf.value = value
                 obj_buf.attrs.update(h5f[name].attrs)
                 return obj_buf, 1
-            else: return Scalar(value=value, attrs=h5f[name].attrs), 1
-
+            else:
+                return Scalar(value=value, attrs=h5f[name].attrs), 1
 
         # Struct
         # recursively build a struct, return as a dictionary
-        if datatype == 'struct':
+        if datatype == "struct":
 
             # ignore obj_buf.
             # TODO: could append new fields or overwrite/concat to existing
@@ -297,23 +323,23 @@ def read_object(self, name: str,
             # loop over fields and read
             obj_dict = {}
             for field in elements:
-                if not field_mask[field]: continue
+                if not field_mask[field]:
+                    continue
                 # TODO: it's strange to pass start_row, n_rows, idx to struct
                 # fields. If they all had shared indexing, they should be in a
                 # table... Maybe should emit a warning? Or allow them to be
                 # dicts keyed by field name?
-                obj_dict[field], _ = self.read_object(name+'/'+field,
-                                                      h5f,
-                                                      start_row=start_row,
-                                                      n_rows=n_rows,
-                                                      idx=idx)
+                obj_dict[field], _ = self.read_object(
+                    name + "/" + field, h5f, start_row=start_row, n_rows=n_rows, idx=idx
+                )
             # modify datatype in attrs if a field_mask was used
             attrs = dict(h5f[name].attrs)
             if field_mask is not None:
                 selected_fields = []
                 for field in elements:
-                    if field_mask[field]: selected_fields.append(field)
-                attrs['datatype'] =  'struct' + '{' + ','.join(selected_fields) + '}'
+                    if field_mask[field]:
+                        selected_fields.append(field)
+                attrs["datatype"] = "struct" + "{" + ",".join(selected_fields) + "}"
             return Struct(obj_dict=obj_dict, attrs=attrs), 1
 
         # Below here is all array-like types. So trim idx if needed
@@ -322,37 +348,45 @@ def read_object(self, name: str,
             i_first_valid = bisect_left(idx[0], start_row)
             idxa = idx[0][i_first_valid:]
             # don't readout more than n_rows indices
-            idx = (idxa[:n_rows],) # works even if n_rows > len(idxa)
+            idx = (idxa[:n_rows],)  # works even if n_rows > len(idxa)
 
         # Table or WaveformTable
-        if datatype == 'table':
+        if datatype == "table":
             col_dict = {}
 
             # read out each of the fields
             rows_read = []
             for field in elements:
-                if not field_mask[field] : continue
+                if not field_mask[field]:
+                    continue
                 fld_buf = None
                 if obj_buf is not None:
                     if not isinstance(obj_buf, Table) or field not in obj_buf:
-                        raise ValueError(f"obj_buf for LGDO Table '{name}' not formatted correctly")
-
-                    else: fld_buf = obj_buf[field]
-                col_dict[field], n_rows_read = self.read_object(name+'/'+field,
-                                                                h5f,
-                                                                start_row=start_row,
-                                                                n_rows=n_rows,
-                                                                idx=idx,
-                                                                obj_buf=fld_buf,
-                                                                obj_buf_start=obj_buf_start)
-                if obj_buf is not None and obj_buf_start+n_rows_read > len(obj_buf):
-                    obj_buf.resize(obj_buf_start+n_rows_read)
+                        raise ValueError(
+                            f"obj_buf for LGDO Table '{name}' not formatted correctly"
+                        )
+
+                    else:
+                        fld_buf = obj_buf[field]
+                col_dict[field], n_rows_read = self.read_object(
+                    name + "/" + field,
+                    h5f,
+                    start_row=start_row,
+                    n_rows=n_rows,
+                    idx=idx,
+                    obj_buf=fld_buf,
+                    obj_buf_start=obj_buf_start,
+                )
+                if obj_buf is not None and obj_buf_start + n_rows_read > len(obj_buf):
+                    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]
             for n in rows_read[1:]:
                 if n != n_rows_read:
-                    log.warning(f"Table '{name}' got strange n_rows_read = {n}, {n_rows_read} was expected")
+                    log.warning(
+                        f"Table '{name}' got strange n_rows_read = {n}, {n_rows_read} was expected"
+                    )
 
             # modify datatype in attrs if a field_mask was used
             attrs = dict(h5f[name].attrs)
@@ -361,18 +395,23 @@ def read_object(self, name: str,
                 for field in elements:
                     if field_mask[field]:
                         selected_fields.append(field)
-                attrs['datatype'] = 'table' + '{' + ','.join(selected_fields) + '}'
+                attrs["datatype"] = "table" + "{" + ",".join(selected_fields) + "}"
 
             # fields have been read out, now return a table
             if obj_buf is None:
                 # if col_dict contains just 3 objects called t0, dt, and values,
                 # return a WaveformTable
-                if len(col_dict) == 3:
-                    if 't0' in col_dict and 'dt' in col_dict and 'values' in col_dict:
-                        table = WaveformTable(t0=col_dict['t0'],
-                                              dt=col_dict['dt'],
-                                              values=col_dict['values'])
-                else: table = Table(col_dict=col_dict, attrs=attrs)
+                if (
+                    len(col_dict) == 3
+                    and "t0" in col_dict
+                    and "dt" in col_dict
+                    and "values" in col_dict
+                ):
+                    table = WaveformTable(
+                        t0=col_dict["t0"], dt=col_dict["dt"], values=col_dict["values"]
+                    )
+                else:
+                    table = Table(col_dict=col_dict, attrs=attrs)
                 # set (write) loc to end of tree
                 table.loc = n_rows_read
                 return table, n_rows_read
@@ -382,36 +421,43 @@ def read_object(self, name: str,
                 # table's size as necessary, warn if any mismatches are found
                 obj_buf.resize(do_warn=True)
                 # set (write) loc to end of tree
-                obj_buf.loc = obj_buf_start+n_rows_read
+                obj_buf.loc = obj_buf_start + n_rows_read
                 # check attributes
                 if set(obj_buf.attrs.keys()) != set(attrs.keys()):
                     raise RuntimeError(
                         f"attrs mismatch. obj_buf.attrs: "
-                        f"{obj_buf.attrs}, h5f[{name}].attrs: {attrs}")
+                        f"{obj_buf.attrs}, h5f[{name}].attrs: {attrs}"
+                    )
                 return obj_buf, n_rows_read
 
         # VectorOfVectors
         # read out vector of vectors of different size
-        if elements.startswith('array'):
+        if elements.startswith("array"):
             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")
+                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(f"{name}/cumulative_length",
-                                                              h5f,
-                                                              start_row=start_row,
-                                                              n_rows=n_rows,
-                                                              obj_buf=cumulen_buf,
-                                                              obj_buf_start=obj_buf_start)
+            cumulative_length, n_rows_read = self.read_object(
+                f"{name}/cumulative_length",
+                h5f,
+                start_row=start_row,
+                n_rows=n_rows,
+                obj_buf=cumulen_buf,
+                obj_buf_start=obj_buf_start,
+            )
             # get a view of just what was read out for cleaner code below
-            this_cumulen_nda = cumulative_length.nda[obj_buf_start:obj_buf_start+n_rows_read]
+            this_cumulen_nda = cumulative_length.nda[
+                obj_buf_start : obj_buf_start + n_rows_read
+            ]
 
             # determine the start_row and n_rows for the flattened_data readout
             da_start = 0
@@ -419,7 +465,7 @@ def read_object(self, name: str,
                 # 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]
+                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:
@@ -427,10 +473,12 @@ def read_object(self, name: str,
                         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}")
+                        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} ??")
+                        f"{start_row} and {start_row+n_rows_read} ??"
+                    )
 
             # determine the number of rows for the flattened_data readout
             da_nrows = this_cumulen_nda[-1] if n_rows_read > 0 else 0
@@ -449,7 +497,7 @@ def read_object(self, name: str,
             # data for this read.
             da_buf_start = 0
             if obj_buf_start > 0:
-                da_buf_start = cumulative_length.nda[obj_buf_start-1]
+                da_buf_start = cumulative_length.nda[obj_buf_start - 1]
                 this_cumulen_nda += da_buf_start
 
             # Now prepare the object buffer if necessary
@@ -458,28 +506,35 @@ def read_object(self, name: str,
                 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)
+                if len(da_buf) < dab_size:
+                    da_buf.resize(dab_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)
+            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,
+            )
             if obj_buf is not None:
                 return obj_buf, n_rows_read
-            return VectorOfVectors(flattened_data=flattened_data,
-                                   cumulative_length=cumulative_length,
-                                   attrs=h5f[name].attrs), n_rows_read
-
+            return (
+                VectorOfVectors(
+                    flattened_data=flattened_data,
+                    cumulative_length=cumulative_length,
+                    attrs=h5f[name].attrs,
+                ),
+                n_rows_read,
+            )
 
         # Array
         # FixedSizeArray
         # ArrayOfEqualSizedArrays
         # read out all arrays by slicing
-        if 'array' in datatype:
+        if "array" in datatype:
             if obj_buf is not None:
                 if not isinstance(obj_buf, Array):
                     raise ValueError(f"obj_buf for '{name}' not an LGDO Array")
@@ -491,23 +546,30 @@ def read_object(self, name: str,
             ds_n_rows = h5f[name].shape[0]
             if idx is not None:
                 if len(idx[0]) > 0 and idx[0][-1] >= ds_n_rows:
-                    log.warning("idx indexed past the end of the array in the file. Culling...")
+                    log.warning(
+                        "idx indexed past the end of the array in the file. Culling..."
+                    )
                     n_rows_to_read = bisect_left(idx[0], ds_n_rows)
                     idx = (idx[0][:n_rows_to_read],)
                 if len(idx[0]) == 0:
                     log.warning("idx empty after culling.")
                 n_rows_to_read = len(idx[0])
-            else: n_rows_to_read = ds_n_rows - start_row
-            if n_rows_to_read > n_rows: n_rows_to_read = n_rows
+            else:
+                n_rows_to_read = ds_n_rows - start_row
+            if n_rows_to_read > n_rows:
+                n_rows_to_read = n_rows
 
             # prepare the selection for the read. Use idx if available
-            if idx is not None: source_sel = idx
-            else: source_sel = np.s_[start_row:start_row+n_rows_to_read]
+            if idx is not None:
+                source_sel = idx
+            else:
+                source_sel = np.s_[start_row : start_row + n_rows_to_read]
 
             # Now read the array
             if obj_buf is not None and n_rows_to_read > 0:
                 buf_size = obj_buf_start + n_rows_to_read
-                if len(obj_buf) < buf_size: obj_buf.resize(buf_size)
+                if len(obj_buf) < buf_size:
+                    obj_buf.resize(buf_size)
                 dest_sel = np.s_[obj_buf_start:buf_size]
                 # NOTE: if your script fails on this line, it may be because you
                 # have to apply this patch to h5py (or update h5py, if it's
@@ -517,43 +579,48 @@ def read_object(self, name: str,
                 if n_rows == 0:
                     tmp_shape = (0,) + h5f[name].shape[1:]
                     nda = np.empty(tmp_shape, h5f[name].dtype)
-                else: nda = h5f[name][source_sel]
+                else:
+                    nda = h5f[name][source_sel]
 
             # special handling for bools
             # (c and Julia store as uint8 so cast to bool)
-            if elements == 'bool':
+            if elements == "bool":
                 nda = nda.astype(np.bool)
 
             # Finally, set attributes and return objects
             attrs = h5f[name].attrs
             if obj_buf is None:
-                if datatype == 'array':
+                if datatype == "array":
                     return Array(nda=nda, attrs=attrs), n_rows_to_read
-                if datatype == 'fixedsize_array':
+                if datatype == "fixedsize_array":
                     return FixedSizeArray(nda=nda, attrs=attrs), n_rows_to_read
-                if datatype == 'array_of_equalsized_arrays':
-                    return ArrayOfEqualSizedArrays(nda=nda,
-                                                   dims=shape,
-                                                   attrs=attrs), n_rows_to_read
+                if datatype == "array_of_equalsized_arrays":
+                    return (
+                        ArrayOfEqualSizedArrays(nda=nda, dims=shape, attrs=attrs),
+                        n_rows_to_read,
+                    )
             else:
                 if set(obj_buf.attrs.keys()) != set(attrs.keys()):
                     raise RuntimeError(
                         f"attrs mismatch. "
                         f"obj_buf.attrs: {obj_buf.attrs}, "
-                        f"h5f[{name}].attrs: {attrs}")
+                        f"h5f[{name}].attrs: {attrs}"
+                    )
                 return obj_buf, n_rows_to_read
 
         raise RuntimeError("don't know how to read datatype {datatype}")
 
-    def write_object(self,
-                     obj: LGDO,
-                     name: str,
-                     lh5_file: str | h5py.File,
-                     group: str | h5py.Group = '/',
-                     start_row: int = 0,
-                     n_rows: int = None,
-                     wo_mode: str = 'append',
-                     write_start: int = 0) -> None:
+    def write_object(
+        self,
+        obj: LGDO,
+        name: str,
+        lh5_file: str | h5py.File,
+        group: str | h5py.Group = "/",
+        start_row: int = 0,
+        n_rows: int = None,
+        wo_mode: str = "append",
+        write_start: int = 0,
+    ) -> None:
         """Write an LGDO into an LH5 file.
 
         Parameters
@@ -587,104 +654,124 @@ def write_object(self,
             row in the output file (if already existing) to start overwriting
             from.
         """
-        if wo_mode == 'write_safe':  wo_mode = 'w'
-        if wo_mode == 'append':  wo_mode = 'a'
-        if wo_mode == 'overwrite': wo_mode = 'o'
-        if wo_mode == 'overwrite_file':
-            wo_mode = 'of'
+        if wo_mode == "write_safe":
+            wo_mode = "w"
+        if wo_mode == "append":
+            wo_mode = "a"
+        if wo_mode == "overwrite":
+            wo_mode = "o"
+        if wo_mode == "overwrite_file":
+            wo_mode = "of"
             write_start = 0
-        if wo_mode != 'w' and wo_mode != 'a' and wo_mode != 'o' and wo_mode != 'of':
+        if wo_mode != "w" and wo_mode != "a" and wo_mode != "o" and wo_mode != "of":
             raise ValueError(f"unknown wo_mode '{wo_mode}'")
 
         # "mode" is for the h5df.File and wo_mode is for this function
         # In hdf5, 'a' is really "modify" -- in addition to appending, you can
         # change any object in the file. So we use file:append for
         # write_object:overwrite.
-        mode = 'w' if wo_mode == 'of' else 'a'
+        mode = "w" if wo_mode == "of" else "a"
         lh5_file = self.gimme_file(lh5_file, mode=mode)
         group = self.gimme_group(group, lh5_file)
-        if wo_mode == 'w' and name in group:
+        if wo_mode == "w" and name in group:
             raise RuntimeError(f"can't overwrite '{name}' in wo_mode 'write_safe'")
 
         # struct or table or waveform table
         if isinstance(obj, Struct):
-            group = self.gimme_group(name, group, grp_attrs=obj.attrs,
-                                     overwrite=(wo_mode=='o'))
+            group = self.gimme_group(
+                name, group, grp_attrs=obj.attrs, overwrite=(wo_mode == "o")
+            )
             for field in obj.keys():
-                self.write_object(obj[field],
-                                  field,
-                                  lh5_file,
-                                  group=group,
-                                  start_row=start_row,
-                                  n_rows=n_rows,
-                                  wo_mode=wo_mode,
-                                  write_start=write_start)
+                self.write_object(
+                    obj[field],
+                    field,
+                    lh5_file,
+                    group=group,
+                    start_row=start_row,
+                    n_rows=n_rows,
+                    wo_mode=wo_mode,
+                    write_start=write_start,
+                )
             return
 
         # scalars
         elif isinstance(obj, Scalar):
             if name in group:
-                log.debug(f"overwriting '{name}' in '{group}'")
+                if wo_mode in ["o", "a"]:
+                    log.debug(f"overwriting {name} in {group}")
+                    del group[name]
+                else:
+                    raise RuntimeError(
+                        f"tried to overwrite {name} in {group} for wo_mode {wo_mode}"
+                    )
             ds = group.create_dataset(name, shape=(), data=obj.value)
             ds.attrs.update(obj.attrs)
             return
 
         # vector of vectors
         elif isinstance(obj, VectorOfVectors):
-            group = self.gimme_group(name, group, grp_attrs=obj.attrs,
-                                     overwrite=(wo_mode=='o'))
-            if n_rows is None or n_rows > obj.cumulative_length.nda.shape[0] - start_row:
+            group = self.gimme_group(
+                name, group, grp_attrs=obj.attrs, overwrite=(wo_mode == "o")
+            )
+            if (
+                n_rows is None
+                or n_rows > obj.cumulative_length.nda.shape[0] - start_row
+            ):
                 n_rows = obj.cumulative_length.nda.shape[0] - start_row
 
             # if appending we need to add an appropriate offset to the
             # cumulative lengths as appropriate for the in-file object
-            offset = 0 # declare here because we have to subtract it off at the end
-            if (wo_mode == 'a' or wo_mode == 'o') and 'cumulative_length' in group:
-                len_cl = len(group['cumulative_length'])
-                if wo_mode == 'a':
+            offset = 0  # declare here because we have to subtract it off at the end
+            if (wo_mode == "a" or wo_mode == "o") and "cumulative_length" in group:
+                len_cl = len(group["cumulative_length"])
+                if wo_mode == "a":
                     write_start = len_cl
                 if len_cl > 0:
-                    offset = group['cumulative_length'][write_start-1]
+                    offset = group["cumulative_length"][write_start - 1]
             # Add offset to obj.cumulative_length itself to avoid memory allocation.
             # Then subtract it off after writing!
             obj.cumulative_length.nda += offset
-            self.write_object(obj.cumulative_length,
-                              'cumulative_length',
-                              lh5_file,
-                              group=group,
-                              start_row=start_row,
-                              n_rows=n_rows,
-                              wo_mode=wo_mode,
-                              write_start=write_start)
+            self.write_object(
+                obj.cumulative_length,
+                "cumulative_length",
+                lh5_file,
+                group=group,
+                start_row=start_row,
+                n_rows=n_rows,
+                wo_mode=wo_mode,
+                write_start=write_start,
+            )
             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)
+            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
         elif isinstance(obj, Array):
             if n_rows is None or n_rows > obj.nda.shape[0] - start_row:
                 n_rows = obj.nda.shape[0] - start_row
-            nda = obj.nda[start_row:start_row+n_rows]
+            nda = obj.nda[start_row : start_row + n_rows]
             # hack to store bools as uint8 for c / Julia compliance
-            if nda.dtype.name == 'bool':
+            if nda.dtype.name == "bool":
                 nda = nda.astype(np.uint8)
             # need to create dataset from ndarray the first time for speed
             # creating an empty dataset and appending to that is super slow!
-            if (wo_mode != 'a' and write_start == 0) or name not in group:
+            if (wo_mode != "a" and write_start == 0) or name not in group:
                 maxshape = (None,) + nda.shape[1:]
-                if wo_mode == 'o' and name in group:
-                    log.debug(f'overwriting {name} in {group}')
+                if wo_mode == "o" and name in group:
+                    log.debug(f"overwriting {name} in {group}")
                     del group[name]
                 ds = group.create_dataset(name, data=nda, maxshape=maxshape)
                 ds.attrs.update(obj.attrs)
@@ -693,7 +780,7 @@ def write_object(self,
             # Now append or overwrite
             ds = group[name]
             old_len = ds.shape[0]
-            if wo_mode == 'a':
+            if wo_mode == "a":
                 write_start = old_len
             add_len = write_start + nda.shape[0] - old_len
             ds.resize(old_len + add_len, axis=0)
@@ -701,7 +788,9 @@ def write_object(self,
             return
 
         else:
-            raise RuntimeError(f"do not know how to write '{name}' of type '{type(obj).__name__}'")
+            raise RuntimeError(
+                f"do not know how to write '{name}' of type '{type(obj).__name__}'"
+            )
 
     def read_n_rows(self, name: str, lh5_file: str | h5py.File) -> int | None:
         """Look up the number of rows in an Array-like object called `name` in
@@ -709,51 +798,55 @@ def read_n_rows(self, name: str, lh5_file: str | h5py.File) -> int | None:
 
         Return ``None`` if it is a :class:`.Scalar` or a :class:`.Struct`."""
         # this is basically a stripped down version of read_object
-        h5f = self.gimme_file(lh5_file, 'r')
+        h5f = self.gimme_file(lh5_file, "r")
         if not h5f or name not in h5f:
             raise KeyError(f"'{name}' not in {lh5_file}")
 
         # get the datatype
-        if 'datatype' not in h5f[name].attrs:
-            raise RuntimeError(f"'{name}' in file {lh5_file} is missing the datatype attribute")
+        if "datatype" not in h5f[name].attrs:
+            raise RuntimeError(
+                f"'{name}' in file {lh5_file} is missing the datatype attribute"
+            )
 
-        datatype = h5f[name].attrs['datatype']
+        datatype = h5f[name].attrs["datatype"]
         datatype, shape, elements = parse_datatype(datatype)
 
         # scalars are dim-0 datasets
-        if datatype == 'scalar':
+        if datatype == "scalar":
             return None
 
         # structs don't have rows
-        if datatype == 'struct':
+        if datatype == "struct":
             return None
 
         # tables should have elements with all the same length
-        if datatype == 'table':
+        if datatype == "table":
             # read out each of the fields
             rows_read = None
             for field in elements:
-                n_rows_read = self.read_n_rows(name+'/'+field, h5f)
-                if not rows_read: rows_read = n_rows_read
+                n_rows_read = self.read_n_rows(name + "/" + field, h5f)
+                if not rows_read:
+                    rows_read = n_rows_read
                 elif rows_read != n_rows_read:
                     log.warning(
                         f"table '{name}' got strange n_rows_read = {rows_read}, "
-                        f"{n_rows_read} was expected")
+                        f"{n_rows_read} was expected"
+                    )
             return rows_read
 
         # length of vector of vectors is the length of its cumulative_length
-        if elements.startswith('array'):
+        if elements.startswith("array"):
             return self.read_n_rows(f"{name}/cumulative_length", h5f)
 
         # return array length (without reading the array!)
-        if 'array' in datatype:
+        if "array" in datatype:
             # compute the number of rows to read
             return h5f[name].shape[0]
 
         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, 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.
 
@@ -769,14 +862,14 @@ def ls(lh5_file: str, lh5_group: str = '') -> list[str]:
     lh5_st = LH5Store()
     # To use recursively, make lh5_file a h5group instead of a string
     if isinstance(lh5_file, str):
-        lh5_file = lh5_st.gimme_file(lh5_file, 'r')
-        if lh5_group.startswith('/'):
+        lh5_file = lh5_st.gimme_file(lh5_file, "r")
+        if lh5_group.startswith("/"):
             lh5_group = lh5_group[1:]
 
-    if lh5_group == '':
-        lh5_group = '*'
+    if lh5_group == "":
+        lh5_group = "*"
 
-    splitpath = lh5_group.split('/', 1)
+    splitpath = lh5_group.split("/", 1)
     matchingkeys = fnmatch.filter(lh5_file.keys(), splitpath[0])
 
     if len(splitpath) == 1:
@@ -788,11 +881,12 @@ def ls(lh5_file: str, lh5_group: str = '') -> list[str]:
         return ret
 
 
-def load_nda(f_list: str | list[str],
-             par_list: list[str],
-             lh5_group: str = '',
-             idx_list: list[np.ndarray | list | tuple] = None
-             ) -> dict[str, np.ndarray]:
+def load_nda(
+    f_list: str | list[str],
+    par_list: list[str],
+    lh5_group: str = "",
+    idx_list: list[np.ndarray | list | tuple] = None,
+) -> dict[str, np.ndarray]:
     r"""Build a dictionary of :class:`numpy.ndarray`\ s from LH5 data.
 
     Given a list of files, a list of LH5 table parameters, and an optional
@@ -822,7 +916,9 @@ def load_nda(f_list: str | list[str],
         if idx_list is not None:
             idx_list = [idx_list]
     if idx_list is not None and len(f_list) != len(idx_list):
-        raise ValueError(f"f_list length ({len(f_list)}) != idx_list length ({len(idx_list)})!")
+        raise ValueError(
+            f"f_list length ({len(f_list)}) != idx_list length ({len(idx_list)})!"
+        )
 
     # Expand wildcards
     f_list = [f for f_wc in f_list for f in sorted(glob.glob(os.path.expandvars(f_wc)))]
@@ -830,15 +926,15 @@ def load_nda(f_list: str | list[str],
     sto = LH5Store()
     par_data = {par: [] for par in par_list}
     for ii, f in enumerate(f_list):
-        f = sto.gimme_file(f, 'r')
+        f = sto.gimme_file(f, "r")
         for par in par_list:
-            if f'{lh5_group}/{par}' not in f:
+            if f"{lh5_group}/{par}" not in f:
                 raise RuntimeError(f"'{lh5_group}/{par}' not in file {f_list[ii]}")
 
             if idx_list is None:
-                data, _ = sto.read_object(f'{lh5_group}/{par}', f)
+                data, _ = sto.read_object(f"{lh5_group}/{par}", f)
             else:
-                data, _ = sto.read_object(f'{lh5_group}/{par}', f, idx=idx_list[ii])
+                data, _ = sto.read_object(f"{lh5_group}/{par}", f, idx=idx_list[ii])
             if not data:
                 continue
             par_data[par].append(data.nda)
@@ -846,11 +942,12 @@ def load_nda(f_list: str | list[str],
     return par_data
 
 
-def load_dfs(f_list: str | list[str],
-             par_list: list[str],
-             lh5_group: str = '',
-             idx_list: list[np.ndarray | list | tuple] = None
-             ) -> pd.DataFrame:
+def load_dfs(
+    f_list: str | list[str],
+    par_list: list[str],
+    lh5_group: str = "",
+    idx_list: list[np.ndarray | list | tuple] = None,
+) -> pd.DataFrame:
     """Build a :class:`pandas.DataFrame` from LH5 data.
 
     Given a list of files (can use wildcards), a list of LH5 columns, and
@@ -868,7 +965,9 @@ def load_dfs(f_list: str | list[str],
         all data for the associated parameters concatenated over all files in
         `f_list`.
     """
-    return pd.DataFrame(load_nda(f_list, par_list, lh5_group=lh5_group, idx_list=idx_list))
+    return pd.DataFrame(
+        load_nda(f_list, par_list, lh5_group=lh5_group, idx_list=idx_list)
+    )
 
 
 class LH5Iterator:
@@ -896,13 +995,17 @@ class LH5Iterator:
     reallocation of memory; this means that if you want to hold on to data
     between reads, you will have to copy it somewhere!
     """
-    def __init__(self, lh5_files: str | list[str],
-                 group: str,
-                 base_path: str = '',
-                 entry_list: list[int] | list[list[int]] = None,
-                 entry_mask: list[bool] | list[list[bool]] = None,
-                 field_mask: dict[str, bool] | list[str] | tuple[str] = None,
-                 buffer_len: int = 3200) -> None:
+
+    def __init__(
+        self,
+        lh5_files: str | list[str],
+        group: str,
+        base_path: str = "",
+        entry_list: list[int] | list[list[int]] = None,
+        entry_mask: list[bool] | list[list[bool]] = None,
+        field_mask: dict[str, bool] | list[str] | tuple[str] = None,
+        buffer_len: int = 3200,
+    ) -> None:
         """
         Parameters
         ----------
@@ -931,17 +1034,23 @@ def __init__(self, lh5_files: str | list[str],
         # 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)))]
+        self.lh5_files = [
+            f for f_wc in lh5_files for f in sorted(glob.glob(os.path.expandvars(f_wc)))
+        ]
         # 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').cumsum()
+        self.file_map = np.array(
+            [self.lh5_st.read_n_rows(group, f) for f in self.lh5_files], "int64"
+        ).cumsum()
         self.group = group
         self.buffer_len = buffer_len
 
         if len(self.lh5_files) > 0:
-            self.lh5_buffer = self.lh5_st.get_buffer(self.group,
-                                                     self.lh5_files[0],
-                                                     size=self.buffer_len,
-                                                     field_mask=field_mask)
+            self.lh5_buffer = self.lh5_st.get_buffer(
+                self.group,
+                self.lh5_files[0],
+                size=self.buffer_len,
+                field_mask=field_mask,
+            )
         else:
             None
 
@@ -964,7 +1073,7 @@ def __init__(self, lh5_files: str | list[str],
                     i_start = i_stop
 
             else:
-                self.entry_list = [[]]*len(self.file_map)
+                self.entry_list = [[]] * len(self.file_map)
                 for i_file, local_list in enumerate(entry_list):
                     self.entry_list[i_file] = list(local_list)
 
@@ -976,27 +1085,32 @@ def __init__(self, lh5_files: str | list[str],
                 self.entry_list = []
                 f_start = 0
                 for f_end in self.file_map:
-                    self.entry_list.append(list(np.nonzero(entry_mask[f_start:f_end])[0]))
+                    self.entry_list.append(
+                        list(np.nonzero(entry_mask[f_start:f_end])[0])
+                    )
                     f_start = f_end
             else:
-                self.entry_list = [[]]*len(self.file_map)
+                self.entry_list = [[]] * len(self.file_map)
                 for i_file, local_mask in enumerate(entry_mask):
                     self.entry_list[i_file] = list(np.nonzero(local_mask)[0])
 
         # Map to last entry of each file
-        self.entry_map = self.file_map if self.entry_list is None else \
-            np.array([len(elist) for elist in self.entry_list]).cumsum()
+        self.entry_map = (
+            self.file_map
+            if self.entry_list is None
+            else np.array([len(elist) for elist in self.entry_list]).cumsum()
+        )
 
     def read(self, entry: int) -> tuple[LGDO, int]:
         """Read the next chunk of events, starting at entry. Return the
         LH5 buffer and number of rows read."""
-        i_file = np.searchsorted(self.entry_map, entry, 'right')
+        i_file = np.searchsorted(self.entry_map, entry, "right")
         local_entry = entry
         if i_file > 0:
-            local_entry -= self.entry_map[i_file-1]
+            local_entry -= self.entry_map[i_file - 1]
         self.n_rows = 0
 
-        while(self.n_rows < self.buffer_len and i_file < len(self.file_map)):
+        while self.n_rows < self.buffer_len and i_file < len(self.file_map):
             # Loop through files
             local_idx = self.entry_list[i_file] if self.entry_list is not None else None
             i_local = local_idx[local_entry] if local_idx is not None else local_entry
@@ -1004,11 +1118,11 @@ def read(self, entry: int) -> tuple[LGDO, int]:
                 self.group,
                 self.lh5_files[i_file],
                 start_row=i_local,
-                n_rows=self.buffer_len-self.n_rows,
+                n_rows=self.buffer_len - self.n_rows,
                 idx=local_idx,
                 field_mask=self.field_mask,
                 obj_buf=self.lh5_buffer,
-                obj_buf_start=self.n_rows
+                obj_buf_start=self.n_rows,
             )
 
             self.n_rows += n_rows
diff --git a/src/pygama/lgdo/scalar.py b/src/pygama/lgdo/scalar.py
index 9e901c451..8c75066eb 100644
--- a/src/pygama/lgdo/scalar.py
+++ b/src/pygama/lgdo/scalar.py
@@ -14,10 +14,10 @@
 
 class Scalar:
     """Holds just a scalar value and some attributes (datatype, units, ...)."""
+
     # TODO: do scalars need proper numpy dtypes?
 
-    def __init__(self, value: int | float,
-                 attrs: dict[str, Any] = None) -> None:
+    def __init__(self, value: int | float, attrs: dict[str, Any] = None) -> None:
         """
         Parameters
         ----------
@@ -27,22 +27,23 @@ def __init__(self, value: int | float,
             a set of user attributes to be carried along with this LGDO.
         """
         if not np.isscalar(value):
-            raise ValueError('cannot instantiate a Scalar with a non-scalar value')
+            raise ValueError("cannot instantiate a Scalar with a non-scalar value")
 
         self.value = value
         self.attrs = {} if attrs is None else dict(attrs)
 
-        if 'datatype' in self.attrs:
-            if self.attrs['datatype'] != self.form_datatype():
+        if "datatype" in self.attrs:
+            if self.attrs["datatype"] != self.form_datatype():
                 raise ValueError(
                     f"datatype ({self.attrs['datatype']}) does "
-                    f"not match value type ({type(value).__name__})!")
+                    f"not match value type ({type(value).__name__})!"
+                )
         else:
-            self.attrs['datatype'] = get_element_type(self.value)
+            self.attrs["datatype"] = get_element_type(self.value)
 
     def datatype_name(self) -> str:
         """Returns the name for this LGDO's datatype attribute."""
-        if hasattr(self.value, 'datatype_name'):
+        if hasattr(self.value, "datatype_name"):
             return self.value.datatype_name
         else:
             return get_element_type(self.value)
@@ -55,9 +56,9 @@ def __str__(self) -> str:
         """Convert to string (e.g. for printing)."""
         string = str(self.value)
         tmp_attrs = self.attrs.copy()
-        tmp_attrs.pop('datatype')
+        tmp_attrs.pop("datatype")
         if len(tmp_attrs) > 0:
-            string += '\n' + str(tmp_attrs)
+            string += "\n" + str(tmp_attrs)
         return string
 
     def __repr__(self) -> str:
diff --git a/src/pygama/lgdo/struct.py b/src/pygama/lgdo/struct.py
index db3cfb3fb..553aa8016 100644
--- a/src/pygama/lgdo/struct.py
+++ b/src/pygama/lgdo/struct.py
@@ -22,10 +22,12 @@ class Struct(dict):
     After instantiation, add fields using :meth:`add_field` to keep the
     datatype updated, or call :meth:`update_datatype` after adding.
     """
+
     # TODO: overload setattr to require add_field for setting?
 
-    def __init__(self, obj_dict: dict[str, LGDO | Struct] = None,
-                 attrs: dict[str, Any] = None) -> None:
+    def __init__(
+        self, obj_dict: dict[str, LGDO | Struct] = None, attrs: dict[str, Any] = None
+    ) -> None:
         """
         Parameters
         ----------
@@ -40,25 +42,26 @@ def __init__(self, obj_dict: dict[str, LGDO | Struct] = None,
 
         self.attrs = {} if attrs is None else dict(attrs)
 
-        if 'datatype' in self.attrs:
-            if self.attrs['datatype'] != self.form_datatype():
+        if "datatype" in self.attrs:
+            if self.attrs["datatype"] != self.form_datatype():
                 raise ValueError(
                     "datatype does not match obj_dict! "
                     f"datatype: {self.attrs['datatype']}, "
                     f"obj_dict.keys(): {obj_dict.keys()}, "
-                    f"form_datatype(): {self.form_datatype()}")
+                    f"form_datatype(): {self.form_datatype()}"
+                )
         self.update_datatype()
 
     def datatype_name(self) -> str:
         """The name for this LGDO's datatype attribute."""
-        return 'struct'
+        return "struct"
 
     def form_datatype(self) -> str:
         """Return this LGDO's datatype attribute string."""
-        return self.datatype_name() + '{' + ','.join(self.keys()) + '}'
+        return self.datatype_name() + "{" + ",".join(self.keys()) + "}"
 
     def update_datatype(self) -> None:
-        self.attrs['datatype'] = self.form_datatype()
+        self.attrs["datatype"] = self.form_datatype()
 
     def add_field(self, name: str, obj: LGDO | Struct) -> None:
         self[name] = obj
@@ -71,11 +74,11 @@ def __len__(self) -> int:
     def __str__(self) -> str:
         """Convert to string (e.g. for printing)."""
         tmp_attrs = self.attrs.copy()
-        datatype = tmp_attrs.pop('datatype')
+        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)
+            string += "\nattrs = " + str(tmp_attrs)
         return string
 
     def __repr__(self) -> str:
diff --git a/src/pygama/lgdo/table.py b/src/pygama/lgdo/table.py
index 8252e7696..dd0aa5bef 100644
--- a/src/pygama/lgdo/table.py
+++ b/src/pygama/lgdo/table.py
@@ -32,10 +32,15 @@ class Table(Struct):
     resize it before passing to data processing functions, as they will call
     :meth:`__len__` to access valid data, which returns the ``size`` attribute.
     """
+
     # TODO: overload getattr to allow access to fields as object attributes?
 
-    def __init__(self, size: int = None, col_dict: dict[str, LGDO] = None,
-                 attrs: dict[str, Any] = None) -> None:
+    def __init__(
+        self,
+        size: int = None,
+        col_dict: dict[str, LGDO] = None,
+        attrs: dict[str, Any] = None,
+    ) -> None:
         r"""
         Parameters
         ----------
@@ -76,7 +81,7 @@ def __init__(self, size: int = None, col_dict: dict[str, LGDO] = None,
 
     def datatype_name(self) -> str:
         """The name for this LGDO's datatype attribute."""
-        return 'table'
+        return "table"
 
     def __len__(self) -> int:
         """Provides ``__len__`` for this array-like class."""
@@ -89,8 +94,10 @@ def resize(self, new_size: int = None, do_warn: bool = False) -> None:
                 new_size = len(obj)
             elif len(obj) != new_size:
                 if do_warn:
-                    log.warning(f'warning: resizing field {field}',
-                                f'with size {len(obj)} != {new_size}')
+                    log.warning(
+                        f"warning: resizing field {field}",
+                        f"with size {len(obj)} != {new_size}",
+                    )
                 if isinstance(obj, Table):
                     obj.resize(new_size)
                 else:
@@ -106,8 +113,9 @@ def is_full(self) -> bool:
     def clear(self) -> None:
         self.loc = 0
 
-    def add_field(self, name: str, obj: LGDO,
-                  use_obj_size: bool = False, do_warn=True) -> None:
+    def add_field(
+        self, name: str, obj: LGDO, use_obj_size: bool = False, do_warn=True
+    ) -> None:
         """Add a field (column) to the table.
 
         Use the name "field" here to match the terminology used in
@@ -125,8 +133,8 @@ def add_field(self, name: str, obj: LGDO,
             print or don't print useful info. Passed to :meth:`resize` when
             `use_obj_size` is ``True``.
         """
-        if not hasattr(obj, '__len__'):
-            raise TypeError('cannot add field of type', type(obj).__name__)
+        if not hasattr(obj, "__len__"):
+            raise TypeError("cannot add field of type", type(obj).__name__)
 
         super().add_field(name, obj)
 
@@ -135,13 +143,15 @@ def add_field(self, name: str, obj: LGDO,
             new_size = len(obj) if use_obj_size else self.size
             self.resize(new_size=new_size)
 
-    def add_column(self, name: str, obj: LGDO, use_obj_size: bool = False,
-                   do_warn: bool = True) -> None:
+    def add_column(
+        self, name: str, obj: LGDO, use_obj_size: bool = False, do_warn: bool = True
+    ) -> None:
         """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 join(self, other_table: Table, cols: list[str] = None,
-             do_warn: bool = True) -> None:
+    def join(
+        self, other_table: Table, cols: list[str] = None, do_warn: bool = True
+    ) -> None:
         """Add the columns of another table to this table.
 
         Notes
@@ -169,8 +179,7 @@ def join(self, other_table: Table, cols: list[str] = None,
         for name in cols:
             self.add_column(name, other_table[name], do_warn=do_warn)
 
-    def get_dataframe(self, cols: list[str] = None,
-                      copy: bool = False) -> pd.DataFrame:
+    def get_dataframe(self, cols: list[str] = None, copy: bool = False) -> pd.DataFrame:
         """Get a :class:`pandas.DataFrame` from the data in the table.
 
         Notes
@@ -190,8 +199,8 @@ def get_dataframe(self, cols: list[str] = None,
         if cols is None:
             cols = self.keys()
         for col in cols:
-            if not hasattr(self[col], 'nda'):
-                raise ValueError(f'column {col} does not have an nda')
+            if not hasattr(self[col], "nda"):
+                raise ValueError(f"column {col} does not have an nda")
             else:
                 df[col] = self[col].nda
 
diff --git a/src/pygama/lgdo/vectorofvectors.py b/src/pygama/lgdo/vectorofvectors.py
index 3f6d843cc..c0971e64c 100644
--- a/src/pygama/lgdo/vectorofvectors.py
+++ b/src/pygama/lgdo/vectorofvectors.py
@@ -8,6 +8,7 @@
 from typing import Any
 
 import numpy as np
+from numba import njit
 
 from pygama.lgdo.array import Array
 from pygama.lgdo.lgdo_utils import get_element_type
@@ -23,9 +24,14 @@ class VectorOfVectors:
     to store the cumulative sum of lengths of each vector.
     """
 
-    def __init__(self, flattened_data: Array = None,
-                 cumulative_length: Array = None, shape_guess: tuple[int, int] = None,
-                 dtype: np.dtype = None, attrs: dict[str, Any] = None) -> None:
+    def __init__(
+        self,
+        flattened_data: Array = None,
+        cumulative_length: Array = None,
+        shape_guess: tuple[int, int] = None,
+        dtype: np.dtype = None,
+        attrs: dict[str, Any] = None,
+    ) -> None:
         """
         Parameters
         ----------
@@ -52,13 +58,15 @@ def __init__(self, flattened_data: Array = None,
             A set of user attributes to be carried along with this LGDO.
         """
         if cumulative_length is None:
-            self.cumulative_length = Array(shape=(shape_guess[0],), dtype='uint32', fill_val=0)
+            self.cumulative_length = Array(
+                shape=(shape_guess[0],), dtype="uint32", fill_val=0
+            )
         else:
             self.cumulative_length = cumulative_length
         if flattened_data is None:
             length = np.prod(shape_guess)
             if dtype is None:
-                raise ValueError('flattened_data and dtype cannot both be None!')
+                raise ValueError("flattened_data and dtype cannot both be None!")
             else:
                 self.flattened_data = Array(shape=(length,), dtype=dtype)
                 self.dtype = np.dtype(dtype)
@@ -71,18 +79,19 @@ def __init__(self, flattened_data: Array = None,
 
         self.attrs = {} if attrs is None else dict(attrs)
 
-        if 'datatype' in self.attrs:
-            if self.attrs['datatype'] != self.form_datatype():
+        if "datatype" in self.attrs:
+            if self.attrs["datatype"] != self.form_datatype():
                 log.warning(
                     f"datatype does not match dtype! "
                     f"datatype: {self.attrs['datatype']}, "
-                    f"form_datatype(): {self.form_datatype()}")
+                    f"form_datatype(): {self.form_datatype()}"
+                )
         else:
-            self.attrs['datatype'] = self.form_datatype()
+            self.attrs["datatype"] = self.form_datatype()
 
     def datatype_name(self) -> str:
         """The name for this LGDO's datatype attribute."""
-        return 'array'
+        return "array"
 
     def __len__(self) -> int:
         """Provides ``__len__`` for this array-like class."""
@@ -94,7 +103,7 @@ def resize(self, new_size: int) -> None:
     def form_datatype(self) -> str:
         """Return this LGDO's datatype attribute string."""
         et = get_element_type(self)
-        return 'array<1>{array<1>{' + et + '}}'
+        return "array<1>{array<1>{" + et + "}}"
 
     def set_vector(self, i_vec: int, nda: np.ndarray) -> None:
         """Insert vector `nda` at location `i_vec`.
@@ -104,16 +113,18 @@ def set_vector(self, i_vec: int, nda: np.ndarray) -> None:
         `flattened_data` is doubled in length until `nda` can be appended to
         it.
         """
-        if i_vec < 0 or i_vec > len(self.cumulative_length.nda)-1:
-            raise ValueError('bad i_vec', i_vec)
+        if i_vec < 0 or i_vec > len(self.cumulative_length.nda) - 1:
+            raise ValueError("bad i_vec", i_vec)
 
         if len(nda.shape) != 1:
-            raise ValueError('nda had bad shape', nda.shape)
+            raise ValueError("nda had bad shape", nda.shape)
 
-        start = 0 if i_vec == 0 else self.cumulative_length.nda[i_vec-1]
+        start = 0 if i_vec == 0 else self.cumulative_length.nda[i_vec - 1]
         end = start + len(nda)
         while end >= len(self.flattened_data.nda):
-            self.flattened_data.nda.resize(2*len(self.flattened_data.nda), refcheck=True)
+            self.flattened_data.nda.resize(
+                2 * len(self.flattened_data.nda), refcheck=True
+            )
         self.flattened_data.nda[start:end] = nda
         self.cumulative_length.nda[i_vec] = end
 
@@ -127,7 +138,7 @@ def __next__(self) -> np.ndarray:
                 start = 0
                 end = self.cumulative_length.nda[0]
             else:
-                start = self.cumulative_length.nda[self.index-1]
+                start = self.cumulative_length.nda[self.index - 1]
                 end = self.cumulative_length.nda[self.index]
             result = self.flattened_data.nda[start:end]
         except IndexError:
@@ -143,10 +154,198 @@ def __str__(self) -> str:
         nda = list(self)
         string = str(nda)
         tmp_attrs = self.attrs.copy()
-        tmp_attrs.pop('datatype')
+        tmp_attrs.pop("datatype")
         if len(tmp_attrs) > 0:
-            string += '\n' + str(tmp_attrs)
+            string += "\n" + str(tmp_attrs)
         return string
 
     def __repr__(self) -> str:
         return str(self)
+
+
+def build_cl(
+    sorted_array_in: Array, cumulative_length_out: np.ndarray = None
+) -> np.ndarray:
+    """build a cumulative_length array from an array of sorted data
+
+    So for example if sorted_array_in contains [ 3, 3, 3, 4 ], would return
+    [ 2, 3 ]
+
+    For a sorted_array_in of indices, this is the inverse of explode_cl() below,
+    in the sense that doing build_cl(explode_cl(cumulative_length)) would
+    recover the original cumulative_length.
+
+    Parameters
+    ----------
+    sorted_array_in
+        Array of data already sorted; each N matching contiguous entries will be
+        converted into a new row of cumulative_length_out
+    cumulative_length_out
+        This is an optional pre-allocated array for the output
+        cumulative_length. It will always have length <= sorted_array_in, so
+        giving them the same length is safe if there is not a better guess.
+
+    Returns
+    -------
+    cumulative_length_out
+        The output cumulative_length. If the user provides a
+        cumulative_length_out that is too long, this return value is sliced to
+        contain only the used portion of the allocated memory
+    """
+    if len(sorted_array_in) == 0:
+        return None
+    sorted_array_in = np.asarray(sorted_array_in)
+    if cumulative_length_out is None:
+        cumulative_length_out = np.zeros(len(sorted_array_in), dtype=np.uint64)
+    else:
+        cumulative_length_out.fill(0)
+    if len(cumulative_length_out) == 0 and len(sorted_array_in) > 0:
+        raise ValueError(
+            "cumulative_length_out too short ({len(cumulative_length_out)})"
+        )
+    return nb_build_cl(sorted_array_in, cumulative_length_out)
+
+
+@njit
+def nb_build_cl(
+    sorted_array_in: np.ndarray, cumulative_length_out: np.ndarray
+) -> np.ndarray:
+    """numbified inner loop for build_cl"""
+    ii = 0
+    last_val = sorted_array_in[0]
+    for val in sorted_array_in:
+        if val != last_val:
+            ii += 1
+            cumulative_length_out[ii] = cumulative_length_out[ii - 1]
+            if ii >= len(cumulative_length_out):
+                raise RuntimeError("cumulative_length_out too short")
+            last_val = val
+        cumulative_length_out[ii] += 1
+    ii += 1
+    return cumulative_length_out[:ii]
+
+
+def explode_cl(cumulative_length: Array, array_out: np.ndarray = None) -> np.ndarray:
+    """explode a cumulative_length array
+
+    So for example if cumulative_length is [ 2, 3 ], would return [ 0, 0, 0, 1]
+
+    This is the inverse of build_cl() above, in the sense that doing
+    build_cl(explode_cl(cumulative_length)) would recover the original
+    cumulative_length.
+
+    Parameters
+    ----------
+    cumulative_length
+        the cumulative_length array to be exploded
+    array_out
+        an optional pre-allocated array to hold the exploded cumulative_length.
+        The length should be equal to cumulative_length[-1]
+
+    Returns
+    -------
+    array_out
+        the exploded cumulative_length array
+    """
+    cumulative_length = np.asarray(cumulative_length)
+    out_len = cumulative_length[-1] if len(cumulative_length) > 0 else 0
+    if array_out is None:
+        array_out = np.empty(int(out_len), dtype=np.uint64)
+    if len(array_out) != out_len:
+        raise ValueError(
+            f"bad lengths: cl[-1] ({cumulative_length[-1]}) != out ({len(array_out)})"
+        )
+    return nb_explode_cl(cumulative_length, array_out)
+
+
+@njit
+def nb_explode_cl(cumulative_length: np.ndarray, array_out: np.ndarray) -> np.ndarray:
+    """numbified inner loop for explode_cl"""
+    out_len = cumulative_length[-1] if len(cumulative_length) > 0 else 0
+    if len(array_out) != out_len:
+        raise ValueError("bad lengths")
+    start = 0
+    for ii in range(len(cumulative_length)):
+        nn = int(cumulative_length[ii] - start)
+        for jj in range(nn):
+            array_out[int(start + jj)] = ii
+        start = cumulative_length[ii]
+    return array_out
+
+
+def explode(
+    cumulative_length: Array, array_in: Array, array_out: np.ndarray = None
+) -> np.ndarray:
+    """explode a data array using a cumulative_length array
+
+    This is identical to allocated_explode_cl, except array_in gets exploded
+    instead of cumulative_length. So for example, if array_in = [ 3, 4 ] and
+    cumulative_length = [ 2, 3 ], array_out would be [ 3, 3, 3, 4 ]
+
+    Parameters
+    ----------
+    cumulative_length
+        the cumulative_length array to use for exploding
+    array_in
+        the data to be exploded. Must have same length as cumulative_length
+    array_out
+        a pre-allocated array to hold the exploded data. The length should be
+        equal to cumulative_length[-1]
+    """
+    cumulative_length = np.asarray(cumulative_length)
+    array_in = np.asarray(array_in)
+    out_len = cumulative_length[-1] if len(cumulative_length) > 0 else 0
+    if array_out is None:
+        array_out = np.empty(out_len, dtype=array_in.dtype)
+    if len(cumulative_length) != len(array_in) or len(array_out) != out_len:
+        raise ValueError(
+            f"bad lengths: cl ({len(cumulative_length)}) != in ({len(array_in)}) and cl[-1] ({cumulative_length[-1]}) != out ({len(array_out)})"
+        )
+    return nb_explode(cumulative_length, array_in, array_out)
+
+
+@njit
+def nb_explode(
+    cumulative_length: np.ndarray, array_in: np.ndarray, array_out: np.ndarray
+) -> np.ndarray:
+    """numbified inner loop for explode"""
+    out_len = cumulative_length[-1] if len(cumulative_length) > 0 else 0
+    if len(cumulative_length) != len(array_in) or len(array_out) != out_len:
+        raise ValueError("bad lengths")
+    ii = 0
+    for jj in range(len(array_out)):
+        while ii < len(cumulative_length) and jj >= cumulative_length[ii]:
+            ii += 1
+        array_out[jj] = array_in[ii]
+    return array_out
+
+
+def explode_arrays(
+    cumulative_length: Array, arrays: list, out_arrays: list = None
+) -> list:
+    """explode a set of arrays using a cumulative_length array
+
+    Parameters
+    ----------
+    cumulative_length
+        the cumulative_length array to use for exploding
+    arrays
+        the data arrays to be exploded. Each array must have same length as
+        cumulative_length
+    out_arrays
+        an optional list of pre-allocated arrays to hold the exploded data. The
+        length of the list should be equal to the number of "arrays", and each
+        entry in array_out should have length cumulative_length[-1]. If not
+        provided, output arrays are allocated for the user.
+    """
+    cumulative_length = np.asarray(cumulative_length)
+    for ii in range(len(arrays)):
+        arrays[ii] = np.asarray(arrays[ii])
+    out_len = cumulative_length[-1] if len(cumulative_length) > 0 else 0
+    if out_arrays is None:
+        out_arrays = []
+        for array in arrays:
+            out_arrays.append(np.empty(out_len, dtype=array.dtype))
+    for ii in range(len(arrays)):
+        explode(cumulative_length, arrays[ii], out_arrays[ii])
+    return out_arrays
diff --git a/src/pygama/lgdo/waveform_table.py b/src/pygama/lgdo/waveform_table.py
index f268be004..9514badb0 100644
--- a/src/pygama/lgdo/waveform_table.py
+++ b/src/pygama/lgdo/waveform_table.py
@@ -41,17 +41,19 @@ class WaveformTable(Table):
     routine is used.
     """
 
-    def __init__(self,
-                 size: int = None,
-                 t0: float | Array | np.ndarray = 0,
-                 t0_units: str = None,
-                 dt: float | Array | np.ndarray = 1,
-                 dt_units: str = None,
-                 values: ArrayOfEqualSizedArrays | VectorOfVectors | np.ndarray = None,
-                 values_units: str = None,
-                 wf_len: int = None,
-                 dtype: np.dtype = None,
-                 attrs: dict[str, Any] = None) -> None:
+    def __init__(
+        self,
+        size: int = None,
+        t0: float | Array | np.ndarray = 0,
+        t0_units: str = None,
+        dt: float | Array | np.ndarray = 1,
+        dt_units: str = None,
+        values: ArrayOfEqualSizedArrays | VectorOfVectors | np.ndarray = None,
+        values_units: str = None,
+        wf_len: int = None,
+        dtype: np.dtype = None,
+        attrs: dict[str, Any] = None,
+    ) -> None:
         r"""
         Parameters
         ----------
@@ -94,37 +96,53 @@ def __init__(self,
         """
 
         if size is None:
-            if hasattr(t0, '__len__'): size = len(t0)
-            elif hasattr(dt, '__len__'): size = len(dt)
-            elif hasattr(values, '__len__'): size = len(values)
-            if size is None: size = 1024
+            if hasattr(t0, "__len__"):
+                size = len(t0)
+            elif hasattr(dt, "__len__"):
+                size = len(dt)
+            elif hasattr(values, "__len__"):
+                size = len(values)
+            if size is None:
+                size = 1024
 
         if not isinstance(t0, Array):
             shape = (size,)
-            t0_dtype = t0.dtype if hasattr(t0, 'dtype') else np.float32
-            nda = t0 if isinstance(t0, np.ndarray) else np.full(shape, t0, dtype=t0_dtype)
-            if nda.shape != shape: nda.resize(shape, refcheck=True)
+            t0_dtype = t0.dtype if hasattr(t0, "dtype") else np.float32
+            nda = (
+                t0 if isinstance(t0, np.ndarray) else np.full(shape, t0, dtype=t0_dtype)
+            )
+            if nda.shape != shape:
+                nda.resize(shape, refcheck=True)
             t0 = Array(nda=nda)
-        if t0_units is not None: t0.attrs['units'] = f'{t0_units}'
+        if t0_units is not None:
+            t0.attrs["units"] = f"{t0_units}"
 
         if not isinstance(dt, Array):
             shape = (size,)
-            dt_dtype = dt.dtype if hasattr(dt, 'dtype') else np.float32
-            nda = dt if isinstance(dt, np.ndarray) else np.full(shape, dt, dtype=dt_dtype)
-            if nda.shape != shape: nda.resize(shape, refcheck=True)
+            dt_dtype = dt.dtype if hasattr(dt, "dtype") else np.float32
+            nda = (
+                dt if isinstance(dt, np.ndarray) else np.full(shape, dt, dtype=dt_dtype)
+            )
+            if nda.shape != shape:
+                nda.resize(shape, refcheck=True)
             dt = Array(nda=nda)
-        if dt_units is not None: dt.attrs['units'] = f'{dt_units}'
+        if dt_units is not None:
+            dt.attrs["units"] = f"{dt_units}"
 
-        if not isinstance(values, ArrayOfEqualSizedArrays) and not isinstance(values, VectorOfVectors):
+        if not isinstance(values, ArrayOfEqualSizedArrays) and not isinstance(
+            values, VectorOfVectors
+        ):
             if isinstance(values, np.ndarray):
                 try:
                     wf_len = values.shape[1]
                 except Exception:
                     wf_len = None
-            if wf_len is None: # VectorOfVectors
+            if wf_len is None:  # VectorOfVectors
                 shape_guess = (size, 100)
-                if dtype is None: dtype = np.dtype(np.float64)
-                if values is None: values = VectorOfVectors(shape_guess=shape_guess, dtype=dtype)
+                if dtype is None:
+                    dtype = np.dtype(np.float64)
+                if values is None:
+                    values = VectorOfVectors(shape_guess=shape_guess, dtype=dtype)
                 else:
                     flattened_data = np.concatenate(values)
                     length = 0
@@ -132,33 +150,47 @@ def __init__(self,
                     for i in range(size):
                         length += len(values[i])
                         cumulative_length.append(length)
-                    values = VectorOfVectors(flattened_data=flattened_data, cumulative_length=cumulative_length, dtype=dtype)
-            else: # ArrayOfEqualSizedArrays
+                    values = VectorOfVectors(
+                        flattened_data=flattened_data,
+                        cumulative_length=cumulative_length,
+                        dtype=dtype,
+                    )
+            else:  # ArrayOfEqualSizedArrays
                 shape = (size, wf_len)
                 if dtype is None:
-                    dtype = values.dtype if hasattr(values, 'dtype') else np.dtype(np.float64)
-                nda = values if isinstance(values, np.ndarray) else np.zeros(shape, dtype=dtype)
-                if nda.shape != shape: nda.resize(shape, refcheck=True)
-                values = ArrayOfEqualSizedArrays(dims=(1,1), nda=nda)
-        if values_units is not None: values.attrs['units'] = f'{values_units}'
+                    dtype = (
+                        values.dtype
+                        if hasattr(values, "dtype")
+                        else np.dtype(np.float64)
+                    )
+                nda = (
+                    values
+                    if isinstance(values, np.ndarray)
+                    else np.zeros(shape, dtype=dtype)
+                )
+                if nda.shape != shape:
+                    nda.resize(shape, refcheck=True)
+                values = ArrayOfEqualSizedArrays(dims=(1, 1), nda=nda)
+        if values_units is not None:
+            values.attrs["units"] = f"{values_units}"
 
         col_dict = {}
-        col_dict['t0'] = t0
-        col_dict['dt'] = dt
-        col_dict['values'] = values
+        col_dict["t0"] = t0
+        col_dict["dt"] = dt
+        col_dict["values"] = values
         super().__init__(size=size, col_dict=col_dict, attrs=attrs)
 
     @property
     def values(self) -> ArrayOfEqualSizedArrays | VectorOfVectors:
-        return self['values']
+        return self["values"]
 
     @property
     def values_units(self) -> str:
-        return self.values.attrs.get('units', None)
+        return self.values.attrs.get("units", None)
 
     @values_units.setter
     def values_units(self, units) -> None:
-        self.values.attrs['units'] = f'{units}'
+        self.values.attrs["units"] = f"{units}"
 
     @property
     def wf_len(self) -> int:
@@ -182,24 +214,24 @@ def resize_wf_len(self, new_len: int) -> None:
 
     @property
     def t0(self) -> Array:
-        return self['t0']
+        return self["t0"]
 
     @property
     def t0_units(self) -> str:
-        return self.t0.attrs.get('units', None)
+        return self.t0.attrs.get("units", None)
 
     @t0_units.setter
     def t0_units(self, units: str) -> None:
-        self.t0.attrs['units'] = f'{units}'
+        self.t0.attrs["units"] = f"{units}"
 
     @property
     def dt(self) -> Array:
-        return self['dt']
+        return self["dt"]
 
     @property
     def dt_units(self) -> str:
-        return self.dt.attrs.get('units', None)
+        return self.dt.attrs.get("units", None)
 
     @dt_units.setter
     def dt_units(self, units: str) -> None:
-        self.dt.attrs['units'] = f'{units}'
+        self.dt.attrs["units"] = f"{units}"
diff --git a/src/pygama/math/binned_fitting.py b/src/pygama/math/binned_fitting.py
index 59b802b08..bbde09a45 100644
--- a/src/pygama/math/binned_fitting.py
+++ b/src/pygama/math/binned_fitting.py
@@ -211,8 +211,7 @@ def gauss_mode_width_max(hist, bins, var=None, mode_guess=None, n_bins=5,
 
     Returns
     -------
-    pars : 3-tuple containing the parameters (mode, sigma, maximum) of the
-        gaussian fit
+    pars : ndarray containing the parameters (mode, sigma, maximum) of the gaussian fit
 
         - mode : the estimated x-position of the maximum
         - sigma : the estimated width of the peak. Equivalent to a gaussian
@@ -221,7 +220,7 @@ def gauss_mode_width_max(hist, bins, var=None, mode_guess=None, n_bins=5,
           underlying function in the vicinity of the max is given by max /
           sigma^2
         - maximum : the estimated maximum value of the peak
-    cov : 3x3 matrix of floats
+    cov : 3x3 ndarray of floats
         The covariance matrix for the 3 parameters in pars
     """
     bin_centers = pgh.get_bin_centers(bins)
@@ -235,19 +234,15 @@ def gauss_mode_width_max(hist, bins, var=None, mode_guess=None, n_bins=5,
     width_guess = (bin_centers[i_n] - bin_centers[i_0])
     vv = None if var is None else var[i_0:i_n]
     guess = (mode_guess, width_guess, amp_guess)
-    try:
-        pars, errors, cov = fit_binned(nb_gauss_amp, hist[i_0:i_n], bins[i_0:i_n+1], vv,
-                         guess=guess, cost_func=cost_func)
-    except:
-        raise RuntimeError("fit binned failed to work")
+    pars, errors, cov = fit_binned(nb_gauss_amp, hist[i_0:i_n], bins[i_0:i_n+1], vv, guess=guess, cost_func=cost_func)
     if pars[1] < 0: pars[1] = -pars[1]
     if inflate_errors:
         chi2, dof = goodness_of_fit(hist, bins, var, nb_gauss_amp, pars)
         if chi2 > dof: cov *= chi2/dof
-    return pars, cov
+    return np.asarray([pars['mu'], pars['sigma'], pars['a']]), np.asarray(cov)
 
 
-def gauss_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=False, inflate_errors=False, gof_method='var'):
+def gauss_mode_max(hist, bins, **kwargs):
     """Alias for gauss_mode_width_max that just returns the max and mode
 
     See Also
@@ -256,26 +251,42 @@ def gauss_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=Fa
 
     Returns
     -------
-    pars : 2-tuple with the parameters (mode, maximum) of the gaussian fit
+    pars : ndarray with the parameters (mode, maximum) of the gaussian fit
         mode : the estimated x-position of the maximum
         maximum : the estimated maximum value of the peak
-    cov : 2x2 matrix of floats
+    cov : 2x2 ndarray of floats
         The covariance matrix for the 2 parameters in pars
 
     Examples
     --------
-    >>> import pygama.analysis.histograms as pgh
+    >>> import pygama.math.histograms as pgh
     >>> from numpy.random import normal
-    >>> import pygama.analysis.peak_fitting as pgf
+    >>> import pygama.math.peak_fitting as pgf
     >>> hist, bins, var = pgh.get_hist(normal(size=10000), bins=100, range=(-5,5))
-    >>> pgf.gauss_mode_max(hist, bins, var, n_bins=20)
+    >>> pgf.gauss_mode_max(hist, bins, var=var, n_bins=20)
     """
-    pars, cov = gauss_mode_width_max(hist, bins, var, mode_guess, n_bins, poissonLL)
+    pars, cov = gauss_mode_width_max(hist, bins, **kwargs)
     if pars is None or cov is None:
         raise RuntimeError("fit binned failed to work")
     return pars[::2], cov[::2, ::2] # skips "sigma" rows and columns
 
 
+def gauss_mode(hist, bins, **kwargs):
+    """Alias for gauss_mode_max that just returns the mode (position) of a peak
+    See Also
+    --------
+    gauss_mode_max
+    gauss_mode_width_max
+    Returns
+    -------
+    mode : the estimated x-position of the maximum
+    dmode : the uncertainty in the mode
+    """
+    pars, cov = gauss_mode_width_max(hist, bins, **kwargs)
+    if pars is None or cov is None: return None, None
+    return pars[0], np.sqrt(cov[0, 0])
+
+
 def taylor_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=False):
     """Get the max and mode of a peak based on Taylor exp near the max
     Returns the amplitude and position of a peak based on a poly fit over n_bins
diff --git a/src/pygama/math/histogram.py b/src/pygama/math/histogram.py
index 1e57598cf..e8060a8e1 100644
--- a/src/pygama/math/histogram.py
+++ b/src/pygama/math/histogram.py
@@ -451,7 +451,7 @@ def plot_hist(hist, bins, var=None, show_stats=False, stats_hloc=0.75, stats_vlo
         dmean = stddev/np.sqrt(N)
 
         mean, dmean = pgu.get_formatted_stats(mean, dmean, 2)
-        stats = fr'$\mu={mean} \pm {dmean}$\n$\sigma={stddev:#.3g}$'
+        stats = f'$\\mu={mean} \\pm {dmean}$\n$\\sigma={stddev:#.3g}$'
         stats_fontsize = rcParams['legend.fontsize']
         plt.text(stats_hloc, stats_vloc, stats, transform=plt.gca().transAxes, fontsize = stats_fontsize)
 
diff --git a/src/pygama/math/peak_fitting.py b/src/pygama/math/peak_fitting.py
new file mode 100644
index 000000000..bf2fb44ce
--- /dev/null
+++ b/src/pygama/math/peak_fitting.py
@@ -0,0 +1,1092 @@
+import math
+import sys
+
+import numba as nb
+import numpy as np
+from iminuit import Minuit, cost
+from scipy.optimize import brentq, minimize_scalar
+from scipy.stats import crystalball
+
+import pygama.math.histogram as pgh
+
+limit = np.log(sys.float_info.max)/10
+kwd = {"parallel": False, "fastmath": True}
+
+def fit_hist(func, hist, bins, var=None, guess=None,
+             poissonLL=False, integral=None, method=None, bounds=None):
+    """
+    .. deprecated:: 0.8
+        Replaced by :func:`.fit_binned`. Will be removed in future releases.
+
+    do a binned fit to a histogram (nonlinear least squares).
+    can either do a poisson log-likelihood fit (jason's favourite) or
+    use curve_fit w/ an arbitrary function.
+
+    Parameters
+    ----------
+    func
+        function to be fitted
+    hist, bins, var
+        as in return value of pygama.histograms.get_hist()
+    guess
+        initial parameter guesses. Should be optional -- we can auto-guess for
+        many common functions. But not yet implemented.
+    poissonLL
+        use Poisson stats instead of the Gaussian approximation in each bin.
+        Requires integer stats. You must use parameter bounds to make sure that
+        func does not go negative over the x-range of the histogram.
+    integral
+        DOCME
+    method, bounds
+        options to pass to :func:`scipy.optimize.minimize`
+
+    Returns
+    -------
+    coeff, cov_matrix : tuple(array, matrix)
+    """
+
+    print("Fit hist is now deprecated use fit_binned instead")
+    print("Rerouting to fit_binned, bounds will not be applied")
+
+    coeff, errors, cov_matrix = fit_binned(func, hist, bins, var=var, guess=guess, cost_func='Least Squares', bounds = None)
+
+    return coeff, cov_matrix
+
+def fit_binned(func, hist, bins, var=None, guess=None,
+             cost_func='LL', Extended=True,  simplex=False, bounds=None, fixed = None):
+    """Do a binned fit to a histogram.
+
+    Default is Extended Log Likelihood fit, with option for either Least
+    Squares or other cost function.
+
+    Parameters
+    ----------
+    func : the function to fit, if using LL as method needs to be a cdf
+    hist, bins, var : histogrammed data
+    guess : initial guess parameters
+    cost_func : cost function to use
+    Extended : run extended or non extended fit
+    simplex : whether to include a round of simpson minimisation before main minimisation
+    bounds : list of tuples with bounds can be None, e.g. [(0,None), (0,10)]
+    fixed : list of parameter indices to fix
+
+    Returns
+    -------
+    coeff : array
+    error : array
+    cov_matrix : array
+    """
+
+
+    if guess is None:
+        print("auto-guessing not yet implemented, you must supply a guess.")
+        return None, None
+
+    if cost_func == 'LL':
+
+        if var is not None:
+            t_arr = np.zeros((len(hist),2))
+            t_arr[:,0] = hist
+            t_arr[:,1] = var
+            hist = t_arr
+
+        if Extended ==True:
+            cost_func = cost.ExtendedBinnedNLL(hist,bins,  func)
+
+        else:
+            cost_func = cost.BinnedNLL( hist,bins, func)
+
+    elif cost_func == 'Least Squares':
+
+        if var is None:
+            var = hist # assume Poisson stats if variances are not provided
+
+        if len(bins) == len(hist)+1:
+            bin_centres = pgh.get_bin_centers(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)
+        zero_errors = (var == 0)
+        mask = ~(zeros & zero_errors)
+        hist = hist[mask]
+        var = np.sqrt(var[mask])
+        xvals = bin_centres[mask]
+        cost_func = cost.LeastSquares(xvals, hist, var, func)
+
+    m = Minuit(cost_func, *guess)
+    if bounds is not None:
+        m.limits = bounds
+    if fixed is not None:
+        for fix in fixed:
+            m.fixed[fix] = True
+    if simplex == True:
+        m.simplex().migrad()
+    else:
+        m.migrad()
+    m.hesse()
+    return m.values, m.errors, m.covariance
+
+def fit_unbinned(func, data, guess=None,
+             Extended=True, cost_func = 'LL',simplex=False,
+             bounds=None, fixed=None):
+    """Do a unbinned fit to data.
+    Default is Extended Log Likelihood fit, with option for other cost functions.
+
+    Parameters
+    ----------
+    func : the function to fit
+    data : the data
+    guess : initial guess parameters
+    Extended : run extended or non extended fit
+    cost_func : cost function to use
+    simplex : whether to include a round of simpson minimisation before main minimisation
+    bounds : list of tuples with bounds can be None, e.g. [(0,None), (0,10)]
+    fixed : list of parameter indices to fix
+    coeff, cov_matrix : tuple(array, matrix)
+    """
+    if guess is None:
+        print("auto-guessing not yet implemented, you must supply a guess.")
+        return None, None
+
+    if cost_func =='LL':
+        if Extended ==True:
+            cost_func = cost.ExtendedUnbinnedNLL(data, func)
+
+        else:
+            cost_func = cost.UnbinnedNLL(data, func)
+
+    m = Minuit(cost_func, *guess)
+    if bounds is not None:
+        m.limits = bounds
+    if fixed is not None:
+        for fix in fixed:
+            m.fixed[fix] = True
+    if simplex == True:
+        m.simplex().migrad()
+    else:
+        m.migrad()
+    m.hesse()
+    return m.values, m.errors, m.covariance
+
+def goodness_of_fit(hist, bins, var, func, pars, method='var'):
+    """Compute chisq and dof of fit
+
+    Parameters
+    ----------
+    hist, bins, var : array, array, array or None
+        histogram data. var can be None if hist is integer counts
+    func : function
+        the function that was fit to the hist
+    pars : array
+        the best-fit pars of func. Assumes all pars are free parameters
+    method : str
+        Sets the choice of "denominator" in the chi2 sum
+        'var': user passes in the variances in var (must not have zeros)
+        'Pearson': use func (hist must contain integer counts)
+        'Neyman': use hist (hist must contain integer counts and no zeros)
+
+    Returns
+    -------
+    chisq : float
+        the summed up value of chisquared
+    dof : int
+        the number of degrees of freedom
+    """
+    # arg checks
+    if method == 'var':
+        if var is None:
+            print("goodness_of_fit: var must be non-None to use method 'var'")
+            return 0, 0
+        if np.any(var==0):
+            print("goodness_of_fit: var cannot contain zeros")
+            return 0, 0
+    if method == 'Neyman' and np.any(hist==0):
+        print("goodness_of_fit: hist cannot contain zeros for Neyman method")
+        return 0, 0
+
+
+    # compute expected values
+    yy = func(pgh.get_bin_centers(bins), *pars) * 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))
+        dof = len(hist) - len(pars)
+        return log_lr, dof
+
+    else:
+        # compute chi2 numerator and denominator
+        numerator = (hist - yy)**2
+        if method == 'var':
+            denominator = var
+        elif method == 'Pearson':
+            denominator = yy
+        elif method == 'Neyman':
+            denominator = hist
+        else:
+            print(f"goodness_of_fit: unknown method {method}")
+            return 0, 0
+
+        # compute chi2 and dof
+        chisq = np.sum(numerator/denominator)
+        dof = len(hist) - len(pars)
+        return chisq, dof
+
+def get_bin_estimates(pars, func, hist, bins, integral=None, **kwargs):
+    """
+    Bin expected means are estimated by f(bin_center)*bin_width. Supply an
+    integrating function to compute the integral over the bin instead.
+    TODO: make default integrating function a numerical method that is off by
+    default.
+    """
+    if integral is None:
+        return func(pgh.get_bin_centers(bins), *pars, **kwargs) * pgh.get_bin_widths(bins)
+    else:
+        return integral(bins[1:], *pars, **kwargs) - integral(bins[:-1], *pars, **kwargs)
+
+
+def poisson_gof(pars, func, hist, bins, integral=None, **kwargs):
+    """
+    The Poisson likelihood does not give a good GOF until the counts are very
+    high and all the poisson stats are roughly gaussian and you don't need it
+    anyway. But the G.O.F. is calculable for the Poisson likelihood. So we do
+    it here.
+    """
+    mu = get_bin_estimates(pars, func, hist, bins, integral, **kwargs)
+    return 2.*np.sum(mu + hist*(np.log( (hist+1.e-99) / (mu+1.e-99) ) + 1))
+
+
+def gauss_mode_width_max(hist, bins, var=None, mode_guess=None, n_bins=5,
+                         cost_func='Least Squares', inflate_errors=False, gof_method='var'):
+    r"""
+    Get the max, mode, and width of a peak based on gauss fit near the max
+    Returns the parameters of a gaussian fit over n_bins in the vicinity of the
+    maximum of the hist (or the max near mode_guess, if provided). This is
+    equivalent to a Taylor expansion around the peak maximum because near its
+    maximum a Gaussian can be approximated by a 2nd-order polynomial in x:
+
+    .. math::
+
+        A \exp[ -(x-\mu)^2 / 2 \sigma^2 ]
+        \simeq A [ 1 - (x-\mu)^2 / 2 \sigma^2 ]
+        = A - (1/2!) (A/\sigma^2) (x-\mu)^2
+
+    The advantage of using a gaussian over a polynomial directly is that the
+    gaussian parameters are the ones we care about most for a peak, whereas for
+    a poly we would have to extract them after the fit, accounting for
+    covariances. The gaussian also better approximates most peaks farther down
+    the peak. However, the gauss fit is nonlinear and thus less stable.
+
+    Parameters
+    ----------
+    hist : array-like
+        The values of the histogram to be fit
+    bins : array-like
+        The bin edges of the histogram to be fit
+    var : array-like (optional)
+        The variances of the histogram values. If not provided, square-root
+        variances are assumed.
+    mode_guess : float (optional)
+        An x-value (not a bin index!) near which a peak is expected. The
+        algorithm fits around the maximum within +/- n_bins of the guess. If not
+        provided, the center of the max bin of the histogram is used.
+    n_bins : int (optional)
+        The number of bins (including the max bin) to be used in the fit. Also
+        used for searching for a max near mode_guess
+    cost_func : str (optional)
+        Passed to fit_binned()
+    inflate_errors : bool (optional)
+        If true, the parameter uncertainties are inflated by sqrt(chi2red)
+        if it is greater than 1
+    gof_method : str (optional)
+        method flag for goodness_of_fit
+
+    Returns
+    -------
+    pars : ndarray containing the parameters (mode, sigma, maximum) of the gaussian fit
+        - mode : the estimated x-position of the maximum
+        - sigma : the estimated width of the peak. Equivalent to a gaussian
+          width (sigma), but based only on the curvature within n_bins of
+          the peak.  Note that the Taylor-approxiamted curvature of the
+          underlying function in the vicinity of the max is given by max /
+          sigma^2
+        - maximum : the estimated maximum value of the peak
+    cov : 3x3 ndarray of floats
+        The covariance matrix for the 3 parameters in pars
+    """
+
+    bin_centers = pgh.get_bin_centers(bins)
+    if mode_guess is not None: i_0 = pgh.find_bin(mode_guess, bins)
+    else:
+        i_0 = np.argmax(hist)
+        mode_guess = bin_centers[i_0]
+    amp_guess = hist[i_0]
+    i_0 -= int(np.floor(n_bins/2))
+    i_n = i_0 + n_bins
+    width_guess = (bin_centers[i_n] - bin_centers[i_0])
+    vv = None if var is None else var[i_0:i_n]
+    guess = (mode_guess, width_guess, amp_guess)
+    pars, errors, cov = fit_binned(gauss_amp, hist[i_0:i_n], bins[i_0:i_n+1], vv, guess=guess, cost_func=cost_func)
+    if pars[1] < 0: pars[1] = -pars[1]
+    if inflate_errors:
+        chi2, dof = goodness_of_fit(hist, bins, var, gauss_amp, pars)
+        if chi2 > dof: cov *= chi2/dof
+    return np.asarray([pars['mu'], pars['sigma'], pars['a']]), np.asarray(cov)
+
+
+def gauss_mode_max(hist, bins, **kwargs):
+    """Alias for gauss_mode_width_max that just returns the max and mode
+
+    See Also
+    --------
+    gauss_mode_width_max
+
+    Returns
+    -------
+    pars : ndarray with the parameters (mode, maximum) of the gaussian fit
+        mode : the estimated x-position of the maximum
+        maximum : the estimated maximum value of the peak
+    cov : 2x2 ndarray of floats
+        The covariance matrix for the 2 parameters in pars
+
+    Examples
+    --------
+    >>> import pygama.math.histogram as pgh
+    >>> from numpy.random import normal
+    >>> import pygama.math.peak_fitting as pgf
+    >>> hist, bins, var = pgh.get_hist(normal(size=10000), bins=100, range=(-5,5))
+    >>> pgf.gauss_mode_max(hist, bins, var=var, n_bins=20)
+    """
+    pars, cov = gauss_mode_width_max(hist, bins, **kwargs)
+    if pars is None or cov is None: return None, None
+    return pars[::2], cov[::2, ::2] # skips "sigma" rows and columns
+
+
+def gauss_mode(hist, bins, **kwargs):
+    """Alias for gauss_mode_max that just returns the mode (position) of a peak
+
+    See Also
+    --------
+    gauss_mode_max
+    gauss_mode_width_max
+
+    Returns
+    -------
+    mode : the estimated x-position of the maximum
+    dmode : the uncertainty in the mode
+    """
+    pars, cov = gauss_mode_width_max(hist, bins, **kwargs)
+    if pars is None or cov is None: return None, None
+    return pars[0], np.sqrt(cov[0,0])
+
+
+def taylor_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=False):
+    """Get the max and mode of a peak based on Taylor exp near the max
+    Returns the amplitude and position of a peak based on a poly fit over n_bins
+    in the vicinity of the maximum of the hist (or the max near mode_guess, if provided)
+
+    Parameters
+    ----------
+    hist : array-like
+        The values of the histogram to be fit. Often: send in a slice around a peak
+    bins : array-like
+        The bin edges of the histogram to be fit
+    var : array-like (optional)
+        The variances of the histogram values. If not provided, square-root
+        variances are assumed.
+    mode_guess : float (optional)
+        An x-value (not a bin index!) near which a peak is expected. The
+        algorithm fits around the maximum within +/- n_bins of the guess. If not
+        provided, the center of the max bin of the histogram is used.
+    n_bins : int
+        The number of bins (including the max bin) to be used in the fit. Also
+        used for searching for a max near mode_guess
+    poissonLL
+        DOCME
+
+    Returns
+    -------
+    pars : 2-tuple with the parameters (mode, max) of the fit
+        mode : the estimated x-position of the maximum
+        maximum : the estimated maximum value of the peak
+    cov : 2x2 matrix of floats
+        The covariance matrix for the 2 parameters in pars
+
+    Examples
+    --------
+    >>> import pygama.analysis.histograms as pgh
+    >>> from numpy.random import normal
+    >>> import pygama.analysis.peak_fitting as pgf
+    >>> hist, bins, var = pgh.get_hist(normal(size=10000), bins=100, range=(-5,5))
+    >>> pgf.taylor_mode_max(hist, bins, var, n_bins=5)
+    """
+
+    if mode_guess is not None: i_0 = pgh.find_bin(mode_guess, bins)
+    else: i_0 = np.argmax(hist)
+    i_0 -= int(np.floor(n_bins/2))
+    i_n = i_0 + n_bins
+    wts = None if var is None else 1/np.sqrt(var[i_0:i_n])
+
+    pars, cov = np.polyfit(pgh.get_bin_centers(bins)[i_0:i_n], hist[i_0:i_n], 2, w=wts, cov='unscaled')
+    mode = -pars[1] / 2 / pars[0]
+    maximum = pars[2] - pars[0] * mode**2
+    # build the jacobian to compute the output covariance matrix
+    jac = np.array( [ [pars[1]/2/pars[0]**2,    -1/2/pars[0],       0],
+                      [pars[1]**2/4/pars[0]**2, -pars[1]/2/pars[0], 1] ] )
+    cov_jact = np.matmul(cov, jac.transpose())
+    cov = np.matmul(jac, cov_jact)
+    return (mode, maximum), cov
+
+@nb.njit(**kwd)
+def nb_erf(x):
+    """
+    Numba version of error function
+    """
+
+    y = np.empty_like(x)
+    for i in nb.prange(len(x)):
+        y[i] = math.erf(x[i])
+    return y
+
+@nb.njit(**kwd)
+def nb_erfc(x):
+    """
+    Numba version of complementary error function
+    """
+
+    y = np.empty_like(x)
+    for i in nb.prange(len(x)):
+        y[i] = math.erfc(x[i])
+    return y
+
+@nb.njit(**kwd)
+def gauss(x, mu, sigma):
+    """
+    Gaussian, unnormalised for use in building pdfs, w/ args: mu, sigma.
+    """
+
+    if sigma ==0: invs=np.nan
+    else: invs = 1.0 / sigma
+    z = (x - mu) * invs
+    return np.exp(-0.5 * z ** 2)
+
+@nb.njit(**kwd)
+def gauss_norm(x, mu, sigma):
+    """
+    Normalised Gaussian, w/ args: mu, sigma.
+    """
+
+    if sigma ==0: invs=np.nan
+    else: invs = 1.0 / sigma
+    z = (x - mu) * invs
+    invnorm = invs/ np.sqrt(2 * np.pi)
+    return np.exp(-0.5 * z ** 2) * invnorm
+
+@nb.njit(**kwd)
+def gauss_cdf(x, mu, sigma):
+    """
+    gaussian cdf, w/ args: mu, sigma.
+    """
+    return 1/2 * (1 + nb_erf((x - mu)/(sigma*np.sqrt(2))))
+
+@nb.njit(**kwd)
+def gauss_amp(x, mu, sigma, a):
+    """
+    Gaussian with height as a parameter for fwhm etc. args mu sigma, amplitude
+    """
+    return a * gauss(x,mu,sigma)
+
+@nb.njit(**kwd)
+def gauss_pdf(x, mu, sigma, n_sig):
+    """
+    Basic Gaussian pdf args; mu, sigma, n_sig (number of signal events)
+    """
+
+    return n_sig * gauss_norm(x,mu,sigma)
+
+
+def gauss_uniform(x, n_sig, mu, sigma, n_bkg, components = False):
+    """
+    define a gaussian signal on a uniform background,
+    args: n_sig mu, sigma for the signal and n_bkg for the background
+    """
+
+    if components==False:
+        return 1/(np.nanmax(x)-np.nanmin(x)) * n_bkg + n_sig * gauss_norm(x,mu,sigma)
+    else:
+        return n_sig * gauss_norm(x,mu,sigma), 1/(np.nanmax(x)-np.nanmin(x)) * n_bkg
+
+
+def gauss_linear(x, n_sig, mu, sigma, n_bkg, b, m, components=False):
+    """
+    gaussian signal + linear background function
+    args: n_sig mu, sigma for the signal and n_bkg,b,m for the background
+    """
+
+
+    norm = (m/2 *np.nanmax(x)**2 + b*np.nanmax(x)) - (m/2 *np.nanmin(x)**2 + b*np.nanmin(x))
+
+    if components==False:
+        return n_bkg/norm * (m * x + b) + n_sig * gauss_norm(x, mu, sigma)
+    else:
+        return  n_sig * gauss_norm(x, mu, sigma), n_bkg/norm * (m * x + b)
+
+@nb.njit(**kwd)
+def step_int(x,mu,sigma, hstep):
+    """
+    Integral of step function w/args mu, sigma, hstep
+    """
+
+    part1 = x+hstep*(x-mu)*nb_erf((x-mu)/(np.sqrt(2)*sigma))
+    part2 = - np.sqrt(2/np.pi)*hstep*sigma*gauss(x,mu,sigma)
+    return  part1-part2
+
+@nb.njit(**kwd)
+def unnorm_step_pdf(x,  mu, sigma, hstep):
+    """
+    Unnormalised step function for use in pdfs
+    """
+
+    invs = (np.sqrt(2)*sigma)
+    z = (x-mu)/invs
+    step_f = 1 + hstep * nb_erf(z)
+    return step_f
+
+@nb.njit(**kwd)
+def step_pdf(x,  mu, sigma, hstep, lower_range=np.inf , upper_range=np.inf):
+    """
+    Normalised step function w/args mu, sigma, hstep
+    Can be used as a component of other fit functions
+    """
+
+    step_f = unnorm_step_pdf(x,  mu, sigma, hstep)
+    if lower_range ==np.inf and upper_range ==np.inf:
+        integral = step_int(np.array([np.nanmin(x), np.nanmax(x)]), mu, sigma, hstep)
+    else:
+        integral = step_int(np.array([lower_range, upper_range]), mu, sigma, hstep)
+
+    norm = integral[1]-integral[0]
+    return step_f/norm
+
+@nb.njit(**kwd)
+def step_cdf(x,mu,sigma, hstep, lower_range=np.inf , upper_range=np.inf):
+    """
+    CDF for step function w/args mu, sigma, hstep
+    """
+
+    cdf = step_int(x,mu,sigma,hstep)
+    if lower_range ==np.inf and upper_range ==np.inf:
+        integral = step_int(np.array([np.nanmin(x), np.nanmax(x)]), mu, sigma, hstep)
+    else:
+        integral = step_int(np.array([lower_range, upper_range]), mu, sigma, hstep)
+    norm = integral[1]-integral[0]
+    cdf =  (1/norm) * cdf
+    c = 1-cdf[-1]
+    return cdf+c
+
+def gauss_step_pdf(x,  n_sig, mu, sigma, n_bkg, hstep, lower_range=np.inf , upper_range=np.inf, components=False):
+    """
+    Pdf for Gaussian on step background
+    args: n_sig mu, sigma for the signal and n_bkg,hstep for the background
+    """
+
+    try:
+        bkg= step_pdf(x, mu, sigma, hstep, lower_range, upper_range)
+    except ZeroDivisionError:
+        bkg = np.zeros_like(x, dtype=np.float64)
+    if np.any(bkg<0):
+            bkg= np.zeros_like(x, dtype=np.float64)
+    pdf = n_sig*gauss_norm(x,mu,sigma) +\
+          n_bkg*bkg
+    if components ==False:
+        return pdf
+    else:
+        return n_sig*gauss_norm(x,mu,sigma), n_bkg*bkg
+
+def extended_gauss_step_pdf(x,  n_sig, mu, sigma, n_bkg, hstep, lower_range=np.inf , upper_range=np.inf, components=False):
+    """
+    Pdf for Gaussian on step background for Compton spectrum, returns also the total number of events for extended unbinned fits
+    args: n_sig mu, sigma for the signal and n_bkg, hstep for the background
+    """
+
+    if components ==False:
+        return n_sig+n_bkg , gauss_step_pdf(x,  n_sig, mu, sigma, n_bkg, hstep, lower_range, upper_range)
+    else:
+        sig, bkg = gauss_step_pdf(x,  n_sig, mu, sigma, n_bkg, hstep,lower_range, upper_range, components=True)
+        return n_sig+n_bkg, sig, bkg
+
+def gauss_step_cdf(x,  n_sig, mu, sigma,n_bkg, hstep, lower_range=np.inf , upper_range=np.inf, components=False):
+    """
+    Cdf for Gaussian on step background
+    args: n_sig mu, sigma for the signal and n_bkg,hstep for the background
+    """
+    try:
+        bkg = step_cdf(x, mu, sigma, hstep, lower_range, upper_range)
+    except ZeroDivisionError:
+        bkg = np.zeros_like(x, dtype=np.float64)
+    if np.any(bkg<0):
+        bkg= np.zeros_like(x, dtype=np.float64)
+    if components ==False:
+        cdf = (1/(n_sig+n_bkg))*(n_sig*gauss_cdf(x, mu, sigma) +\
+          n_bkg*bkg)
+        return cdf
+    else:
+        return (1/(n_sig+n_bkg))*n_sig*gauss_cdf(x, mu, sigma), (1/(n_sig+n_bkg))*(n_bkg*bkg)
+
+@nb.njit(**kwd)
+def gauss_tail_pdf(x, mu, sigma, tau):
+    """
+    A gaussian tail function template
+    Can be used as a component of other fit functions w/args mu,sigma,tau
+    """
+
+    x = np.asarray(x)
+    tmp = ((x-mu)/tau) + ((sigma**2)/(2*tau**2))
+    tail_f = np.where(tmp < limit,
+                      gauss_tail_exact(x, mu, sigma, tau),
+                      gauss_tail_approx(x, mu, sigma, tau))
+    return tail_f
+
+@nb.njit(**kwd)
+def gauss_tail_exact(x, mu, sigma, tau):
+    tmp = ((x-mu)/tau) + ((sigma**2)/(2*tau**2))
+    abstau = np.absolute(tau)
+    tmp = np.where(tmp < limit, tmp, limit)
+    z = (x-mu)/sigma
+    tail_f = (1/(2*abstau)) * np.exp(tmp) * nb_erfc( (tau*z + sigma)/(np.sqrt(2)*abstau))
+    return tail_f
+
+@nb.njit(**kwd)
+def gauss_tail_approx(x, mu, sigma, tau):
+    den = 1/(sigma + tau*(x-mu)/sigma)
+    tail_f = sigma * gauss_norm(x, mu, sigma) * den * (1.-tau*tau*den*den)
+    return tail_f
+
+@nb.njit(**kwd)
+def gauss_tail_integral(x,mu,sigma,tau):
+    """
+    Integral for gaussian tail
+    """
+
+    abstau = np.abs(tau)
+    part1 = (tau/(2*abstau)) * nb_erf((tau*(x-mu) )/(np.sqrt(2)*sigma*abstau))
+    part2 =    tau * gauss_tail_pdf(x,mu,sigma,tau)
+    return part1+part2
+
+@nb.njit(**kwd)
+def gauss_tail_norm(x,mu,sigma,tau, lower_range=np.inf , upper_range=np.inf):
+    """
+    Normalised gauss tail. Note: this is only needed when the fitting range
+    does not include the whole tail
+    """
+
+    tail = gauss_tail_pdf(x,mu,sigma,tau)
+    if lower_range ==np.inf and upper_range ==np.inf:
+        integral = gauss_tail_integral(np.array([np.nanmin(x), np.nanmax(x)]), mu, sigma, tau)
+    else:
+        integral = gauss_tail_integral(np.array([lower_range, upper_range]), mu, sigma, tau)
+    norm = integral[1]-integral[0]
+    return tail/norm
+
+@nb.njit(**kwd)
+def gauss_tail_cdf(x,mu,sigma,tau, lower_range=np.inf , upper_range=np.inf):
+    """
+    CDF for gaussian tail
+    """
+
+    cdf = gauss_tail_integral(x,mu,sigma,tau)
+    if lower_range ==np.inf and upper_range ==np.inf:
+        integral = gauss_tail_integral(np.array([np.nanmin(x), np.nanmax(x)]), mu, sigma, tau)
+    else:
+        integral = gauss_tail_integral(np.array([lower_range, upper_range]), mu, sigma, tau)
+    norm = integral[1]-integral[0]
+    cdf =  (1/norm) * cdf
+    c = 1-cdf[-1]
+    return cdf+c
+
+def gauss_with_tail_pdf(x, mu, sigma,  htail,tau, components=False):
+    """
+    Pdf for gaussian with tail
+    """
+
+    if htail < 0 or htail > 1:
+        if components ==False:
+            return np.full_like(x, np.nan, dtype='float64')
+        else:
+            return np.full_like(x, np.nan, dtype='float64'), np.full_like(x, np.nan, dtype='float64')
+
+    peak = gauss_norm(x,mu,sigma)
+    try:
+        tail = gauss_tail_pdf(x, mu, sigma, tau)
+    except ZeroDivisionError:
+        tail = np.zeros_like(x, dtype=np.float64)
+    if components ==False:
+        return (1-htail)*peak + htail*tail
+    else:
+        return (1-htail)*peak, htail*tail
+
+def gauss_with_tail_cdf(x, mu, sigma, htail,  tau, components=False):
+    """
+    Cdf for gaussian with tail
+    """
+
+    if htail < 0 or htail > 1:
+        if components ==False:
+            return np.full_like(x, np.nan, dtype='float64')
+        else:
+            return np.full_like(x, np.nan, dtype='float64'), np.full_like(x, np.nan, dtype='float64')
+
+    peak = gauss_cdf(x,mu,sigma)
+    try:
+        tail = gauss_tail_cdf(x, mu, sigma, tau)
+    except  ZeroDivisionError:
+        tail = np.zeros_like(x, dtype=np.float64)
+    if components==False:
+        return (1-htail)*peak + htail*tail
+    else:
+        return (1-htail)*peak, htail*tail
+
+def radford_pdf(x, n_sig, mu, sigma, htail, tau, n_bkg, hstep,
+                lower_range=np.inf , upper_range=np.inf,  components=False):
+    """
+    David Radford's HPGe peak shape PDF consists of a gaussian with tail signal
+    on a step background
+    """
+
+    try:
+        bkg= step_pdf(x, mu, sigma, hstep, lower_range, upper_range)
+    except ZeroDivisionError:
+        bkg = np.zeros_like(x, dtype=np.float64)
+    if np.any(bkg<0):
+        bkg = np.zeros_like(x, dtype=np.float64)
+    if components ==False:
+        sig = gauss_with_tail_pdf(x, mu, sigma, htail,  tau)
+        pdf = (n_bkg * bkg +\
+             n_sig *  sig)
+        return pdf
+    else:
+        peak, tail = gauss_with_tail_pdf(x, mu, sigma, htail,  tau, components=components)
+        return n_sig *peak, n_sig*tail, n_bkg * bkg
+
+def extended_radford_pdf(x, n_sig, mu, sigma, htail, tau, n_bkg, hstep,
+                         lower_range=np.inf , upper_range=np.inf, components=False):
+    """
+    Pdf for gaussian with tail signal and step background, also returns number of events
+    """
+
+    if components ==False:
+        return n_sig + n_bkg, radford_pdf(x, n_sig,  mu, sigma, htail, tau, n_bkg, hstep, lower_range, upper_range)
+    else:
+        peak, tail, bkg = radford_pdf(x, n_sig,  mu, sigma, htail, tau, n_bkg, hstep,
+                                      lower_range, upper_range,components=components)
+        return n_sig + n_bkg, peak, tail, bkg
+
+def radford_cdf(x, n_sig, mu, sigma, htail, tau, n_bkg, hstep, lower_range=np.inf , upper_range=np.inf,  components=False):
+    """
+    Cdf for gaussian with tail signal and step background
+    """
+    try:
+        bkg = step_cdf(x, mu, sigma, hstep, lower_range, upper_range)
+    except ZeroDivisionError:
+        bkg = np.zeros_like(x, dtype=np.float64)
+    if np.any(bkg<0):
+        bkg= np.zeros_like(x, dtype=np.float64)
+    if components ==False:
+        sig = gauss_with_tail_cdf(x, mu, sigma, htail)
+        pdf = (1/(n_sig+n_bkg))*(n_sig*gauss_with_tail_cdf(x, mu, sigma, htail,tau) +\
+            n_bkg*bkg)
+        return pdf
+    else:
+        peak, tail = gauss_with_tail_cdf(x, mu, sigma, htail, components= True)
+        return (n_sig/(n_sig+n_bkg))*peak, (n_sig/(n_sig+n_bkg))*tail, (n_bkg/(n_sig+n_bkg))*bkg
+
+def radford_fwhm(sigma, htail, tau,  cov = None):
+    """
+    Return the FWHM of the radford_peak function, ignoring background and step
+    components. If calculating error also need the normalisation for the step
+    function.
+    """
+    # optimize this to find max value
+    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")
+        raise ValueError
+
+    res = minimize_scalar( neg_radford_peak_bgfree,
+                           args=(sigma, htail, tau),
+                           bounds=(-sigma-htail, sigma+htail) )
+    Emax = res.x
+    half_max = -neg_radford_peak_bgfree(Emax, sigma, htail, tau)/2.
+
+    # root find this to find the half-max energies
+    def radford_peak_bgfree_halfmax(E, sigma, htail, tau, half_max):
+        return gauss_with_tail_pdf(np.array([E]), 0, sigma, htail, tau)[0] - half_max
+
+    try:
+        lower_hm = brentq( radford_peak_bgfree_halfmax,
+                       -(2.5*sigma/2 + htail*tau), Emax,
+                       args = (sigma, htail, tau, half_max) )
+    except:
+        lower_hm = brentq( radford_peak_bgfree_halfmax,
+               -(5*sigma + htail*tau), Emax,
+               args = (sigma, htail, tau, half_max) )
+    try:
+        upper_hm = brentq( radford_peak_bgfree_halfmax,
+                       Emax, 2.5*sigma/2,
+                       args = (sigma, htail, tau, half_max) )
+    except:
+        upper_hm = brentq( radford_peak_bgfree_halfmax,
+                   Emax, 5*sigma,
+                   args = (sigma, htail, tau, half_max) )
+
+    if cov is None: return upper_hm - lower_hm
+
+    #calculate uncertainty
+    #amp set to 1, mu to 0, hstep+bg set to 0
+    pars = [1,0, sigma, htail, tau,0,0]
+    step_norm = 1
+    gradmax = radford_parameter_gradient(Emax, pars, step_norm)
+    gradmax *= 0.5
+    grad1 = radford_parameter_gradient(lower_hm, pars,step_norm)
+    grad1 -= gradmax
+    grad1 /= radford_peakshape_derivative(lower_hm, pars,step_norm)
+    grad2 = radford_parameter_gradient(upper_hm, pars,step_norm)
+    grad2 -= gradmax
+    grad2 /= radford_peakshape_derivative(upper_hm, pars,step_norm)
+    grad2 -= grad1
+
+    fwfm_unc = np.sqrt(np.dot(grad2, np.dot(cov, grad2)))
+
+    return upper_hm - lower_hm, fwfm_unc
+
+def radford_peakshape_derivative(E, pars, step_norm):
+    n_sig, mu, sigma, htail, tau, n_bkg, hstep = pars
+
+    sigma = abs(sigma)
+    gaus = gauss_norm(E, mu, sigma)
+    y = (E-mu)/sigma
+    ret = -(1-htail)*(y/sigma)*gaus
+    ret -= htail/tau*(-gauss_tail_pdf(np.array([E,E-1]), mu, sigma, tau)[0]+gaus)
+
+    return n_sig*ret - n_bkg*hstep*gaus/step_norm #need norm factor for bkg
+
+def radford_parameter_gradient(E, pars, step_norm):
+    n_sig, mu, sigma, htail, tau, n_bkg, hstep = pars
+
+    gaus = gauss_norm(np.array([E, E-1]), mu, sigma)[0]
+    tailL = gauss_tail_pdf(np.array([E, E-1]), mu, sigma, tau)[0]
+    if n_bkg ==0:
+        step_f = 0
+    else:
+        step_f = unnorm_step_pdf(np.array([E, E-1]), mu, sigma, hstep)[0] /step_norm
+
+    #some unitless numbers that show up a bunch
+    y = (E-mu)/sigma
+    sigtauL = sigma/tau
+
+    g_n_sig = 0.5*(htail*tailL + (1-htail)*gaus)
+    g_n_bkg = step_f
+
+    g_hs = n_bkg*math.erfc(y/np.sqrt(2))/step_norm
+
+    g_ht = (n_sig/2)*(tailL-gaus)
+
+    #gradient of gaussian part
+    g_mu = (1-htail)*y/sigma*gaus
+    g_sigma = (1-htail)*(y*y +-1)/sigma*gaus
+
+    #gradient of low tail, use approximation if necessary
+    g_mu += htail/tau*(-tailL+gaus)
+    g_sigma += htail/tau*(sigtauL*tailL-(sigtauL-y)*gaus)
+    g_tau = -htail/tau*( (1.+sigtauL*y+sigtauL*sigtauL)*tailL - sigtauL*sigtauL*gaus) * n_sig
+
+    g_mu = n_sig*g_mu + (2*n_bkg*hstep*gaus)/step_norm
+    g_sigma = n_sig*g_sigma + (2*n_bkg*hstep*gaus*y)/(step_norm*np.sqrt(sigma))
+
+    gradient = g_n_sig, g_mu, g_sigma,g_ht, g_tau, g_n_bkg, g_hs
+    return np.array(gradient)
+
+def get_mu_func(func, pars, cov = None, errors=None):
+
+    if  func == gauss_step_cdf or func == gauss_step_pdf or func == extended_gauss_step_pdf:
+        if len(pars) ==5:
+            n_sig, mu, sigma, n_bkg, hstep = pars
+        elif len(pars) ==7:
+            n_sig, mu, sigma, n_bkg, hstep, low_range, high_range = pars
+        if errors is not None:
+            return mu, errors[1]
+        elif cov is not None:
+            return mu, np.sqrt(cov[1][1])
+        else:
+            return mu
+
+    elif  func == radford_cdf or func == radford_pdf or func == extended_radford_pdf:
+        if len(pars) ==7:
+            n_sig, mu, sigma, htail, tau, n_bkg, hstep = pars
+        elif len(pars) ==9:
+            n_sig, mu, sigma, htail, tau, n_bkg, hstep, low_range, high_range = pars
+        if errors is not None:
+            return mu, errors[1]
+        elif cov is not None:
+            return mu, np.sqrt(cov[1][1])
+        else:
+            return mu
+
+    else:
+        print(f'get_mu_func not implemented for {func.__name__}')
+        return None
+
+def get_fwhm_func(func, pars, cov = None):
+
+    if  func == gauss_step_cdf or func == gauss_step_pdf or func == extended_gauss_step_pdf:
+        if len(pars) ==5:
+            n_sig, mu, sigma, n_bkg, hstep = pars
+        elif len(pars) ==7:
+            n_sig, mu, sigma, n_bkg, hstep, low_range, high_range = pars
+        if cov is None:
+            return sigma*2*np.sqrt(2*np.log(2))
+        else:
+            return sigma*2*np.sqrt(2*np.log(2)), np.sqrt(cov[2][2])*2*np.sqrt(2*np.log(2))
+
+    elif  func == radford_cdf or func == radford_pdf or func == extended_radford_pdf:
+        if len(pars) ==7:
+            n_sig, mu, sigma, htail, tau, n_bkg, hstep = pars
+        elif len(pars) ==9:
+            n_sig, mu, sigma, htail, tau, n_bkg, hstep, low_range, high_range = pars
+
+        return radford_fwhm(sigma, htail, tau, cov)
+    else:
+        print(f'get_fwhm_func not implemented for {func.__name__}')
+        return None
+
+def get_total_events_func(func, pars, cov = None, errors=None):
+
+    if  func == gauss_step_cdf or func == gauss_step_pdf or func == extended_gauss_step_pdf:
+        if len(pars) ==5:
+            n_sig, mu, sigma, n_bkg, hstep = pars
+        elif len(pars) ==7:
+            n_sig, mu, sigma, n_bkg, hstep, low_range, high_range = pars
+        if errors is not None:
+            return n_sig+n_bkg, np.sqrt(errors[0]**2 + errors[3]**2)
+        elif cov is not None:
+            return n_sig+n_bkg, np.sqrt(cov[0][0]**2 + cov[3][3]**2)
+        else:
+            return n_sig+n_bkg
+
+    elif  func == radford_cdf or func == radford_pdf or func == extended_radford_pdf:
+        if len(pars) ==7:
+            n_sig, mu, sigma, htail, tau, n_bkg, hstep = pars
+        elif len(pars) ==9:
+            n_sig, mu, sigma, htail, tau, n_bkg, hstep, low_range, high_range = pars
+        if errors is not None:
+            return n_sig+n_bkg, np.sqrt(errors[0]**2 + errors[5]**2)
+        elif cov is not None:
+            return n_sig+n_bkg, np.sqrt(cov[0][0]**2 + cov[5][5]**2)
+        else:
+            return n_sig+n_bkg
+    else:
+        print(f'get_total_events_func not implemented for {func.__name__}')
+        return None
+
+def Am_double(x,  n_sig1, mu1, sigma1,  n_sig2, mu2,sigma2, n_sig3, mu3,sigma3, n_bkg1, hstep1, n_bkg2, hstep2,
+             lower_range=np.inf , upper_range=np.inf, components=False):
+    """
+    A Fit function exclusevly for a 241Am 99keV and 103keV lines situation
+    Consists of
+
+     - three gaussian peaks (two lines + one bkg line in between)
+     - two steps (for the two lines)
+     - two tails (for the two lines)
+    """
+    bkg1 = n_bkg1*step_pdf(x, mu1, sigma1, hstep1, lower_range, upper_range )
+    bkg2 = n_bkg2*step_pdf(x, mu2, sigma2, hstep2, lower_range, upper_range)
+    if np.any(bkg1<0) or np.any(bkg2<0):
+        return 0, np.zeros_like(x)
+    sig1 = n_sig1*gauss_norm(x,mu1,sigma1)
+    sig2 = n_sig2* gauss_norm(x,mu2,sigma2)
+    sig3 = n_sig3* gauss_norm(x,mu3,sigma3)
+    if components ==False:
+        return sig1+sig2+sig3+bkg1+bkg2
+    else:
+        return sig1,sig2,sig3,bkg1,bkg2
+
+def extended_Am_double(x,  n_sig1, mu1, sigma1,  n_sig2, mu2,sigma2, n_sig3, mu3,sigma3,
+                       n_bkg1, hstep1, n_bkg2, hstep2,
+                     lower_range=np.inf , upper_range=np.inf, components=False):
+    if components ==False:
+        return n_sig1+n_sig2+n_sig3 + n_bkg1+n_bkg2, Am_double(n_sig1, mu1, sigma1,  n_sig2, mu2,sigma2,
+                                                               n_sig3, mu3,sigma3,
+                                                               n_bkg1, hstep1, n_bkg2, hstep2,
+                                                                 lower_range, upper_range)
+    else:
+        sig1,sig2,sig3,bkg1,bkg2 = Am_double(n_sig1, mu1, sigma1,  n_sig2, mu2,sigma2, n_sig3, mu3,sigma3,
+                                             n_bkg1, hstep1, n_bkg2, hstep2,
+                                             lower_range , upper_range,components=components)
+        return n_sig1+n_sig2+n_sig3 + n_bkg1+n_bkg2, sig1,sig2,sig3,bkg1,bkg2
+
+
+def double_gauss_pdf(x,  n_sig1,  mu1, sigma1, n_sig2, mu2,sigma2,n_bkg,hstep,
+                     lower_range=np.inf, upper_range=np.inf, components=False):
+    """
+    A Fit function exclusevly for a 133Ba 81keV peak situation
+    Consists of
+
+     - two gaussian peaks (two lines)
+     - one step
+    """
+    bkg = n_bkg*step_pdf(x, mu1, sigma1, hstep, lower_range, upper_range)
+    if np.any(bkg<0):
+        return 0, np.zeros_like(x)
+    sig1 = n_sig1*gauss_norm(x,mu1,sigma1)
+    sig2 = n_sig2* gauss_norm(x,mu2,sigma2)
+    if components == False:
+        return sig1 + sig2 + bkg
+    else:
+        return sig1, sig2, bkg
+
+def extended_double_gauss_pdf(x,  n_sig1,  mu1, sigma1, n_sig2, mu2,sigma2,n_bkg,hstep,
+                     lower_range=np.inf , upper_range=np.inf, components=False):
+    """
+    A Fit function exclusevly for a 133Ba 81keV peak situation
+    Consists of
+
+     - two gaussian peaks (two lines)
+     - one step
+    """
+
+    if components == False:
+        pdf = double_gauss_pdf(x,  n_sig1,  mu1, sigma1, n_sig2, mu2,sigma2,n_bkg,hstep,
+                     lower_range, upper_range)
+        return n_sig1+n_sig2+n_bkg, pdf
+    else:
+        sig1, sig2, bkg = double_gauss_pdf(x,  n_sig1,  mu1, sigma1, n_sig2, mu2,sigma2,n_bkg,hstep,
+                     lower_range, upper_range,components=components)
+        return n_sig1+n_sig2+n_bkg, sig1, sig2, bkg
+
+def xtalball(x, mu, sigma, A, beta, m):
+    """
+    power-law tail plus gaussian https://en.wikipedia.org/wiki/Crystal_Ball_function
+    """
+    return A * crystalball.pdf(x, beta, m, loc=mu, scale=sigma)
+
+
+def cal_slope(x, m1, m2):
+    """
+    Fit the calibration values
+    """
+    return np.sqrt(m1 +(m2/(x**2)))
+
+
+def poly(x, pars):
+    """
+    A polynomial function with pars following the polyfit convention
+    """
+    result = x*0 # do x*0 to keep shape of x (scalar or array)
+    if len(pars) == 0: return result
+    result += pars[-1]
+    for i in range(1, len(pars)):
+        result += pars[-i-1]*x
+        x = x*x
+    return result
diff --git a/src/pygama/math/utils.py b/src/pygama/math/utils.py
index abcc6f6db..b3c8b836b 100644
--- a/src/pygama/math/utils.py
+++ b/src/pygama/math/utils.py
@@ -35,6 +35,10 @@ def get_formatted_stats(mean, sigma, ndigs=2):
     convenience function for formatting mean +/- sigma to the right number of
     significant figures.
     """
+    if sigma == 0:
+        fmt = '%d' % ndigs
+        fmt = '%#.' + fmt + 'g'
+        return fmt % mean, fmt % sigma
     sig_pos = int(np.floor(np.log10(abs(sigma))))
     sig_fmt = '%d' % ndigs
     sig_fmt = '%#.' + sig_fmt + 'g'
diff --git a/src/pygama/raw/__init__.py b/src/pygama/raw/__init__.py
index 2e5baa6ec..b9e31d03e 100644
--- a/src/pygama/raw/__init__.py
+++ b/src/pygama/raw/__init__.py
@@ -9,15 +9,10 @@
 
 Currently we support the following hardware:
 
-* FlashCam (requires `pyfcutils <https://github.com/legend-exp/pyfcutils>`_)
-* SIS3302 read out with `ORCA <https://github.com/unc-enap/Orca>`_
-* GRETINA digitizer (Majorana firmware) read out with ORCA
-
-Partial support is in place but requires updating for
-
-* SIS3316 read out with ORCA
-* SIS3316 read out with LLaMA-DAQ
-* CAEN DT57XX digitizers read out with CoMPASS
+* FlashCam ADC (requires `fcutils <https://github.com/legend-exp/pyfcutils>`_)
+* FlashCam ADC read out with `ORCA <https://github.com/unc-enap/Orca>`_
 """
 
-from .build_raw import build_raw
+from pygama.raw.build_raw import build_raw
+
+__all__ = ["build_raw"]
diff --git a/src/pygama/raw/build_raw.py b/src/pygama/raw/build_raw.py
index d7324a8a6..ed87e1ecf 100644
--- a/src/pygama/raw/build_raw.py
+++ b/src/pygama/raw/build_raw.py
@@ -20,9 +20,15 @@
 log = logging.getLogger(__name__)
 
 
-def build_raw(in_stream: int, in_stream_type: str = None,
-              out_spec: str | dict | RawBufferLibrary = None, buffer_size: int = 8192,
-              n_max: int = np.inf, overwrite: bool = True, **kwargs) -> None:
+def build_raw(
+    in_stream: int,
+    in_stream_type: str = None,
+    out_spec: str | dict | RawBufferLibrary = None,
+    buffer_size: int = 8192,
+    n_max: int = np.inf,
+    overwrite: bool = False,
+    **kwargs,
+) -> None:
     """Convert data into LEGEND HDF5 raw-tier format.
 
     Takes an input stream of a given type and writes to output file(s)
@@ -69,109 +75,135 @@ def build_raw(in_stream: int, in_stream_type: str = None,
     # later: fix if in_stream is not a file
     # (e.g. a socket, which exists if open and has size = np.inf)
     if not os.path.exists(in_stream):
-        raise FileNotFoundError(f'file {in_stream} not found')
+        raise FileNotFoundError(f"file {in_stream} not found")
 
     in_stream_size = os.stat(in_stream).st_size
 
     # try to guess the input stream type if it's not provided
     if in_stream_type is None:
-        i_ext = in_stream.split('/')[-1].rfind('.')
+        i_ext = in_stream.split("/")[-1].rfind(".")
         if i_ext == -1:
             if OrcaStreamer.is_orca_stream(in_stream):
-                in_stream_type = 'ORCA'
+                in_stream_type = "ORCA"
             else:
-                raise RuntimeError('unknown file type. Specify in_stream_type')
+                raise RuntimeError("unknown file type. Specify in_stream_type")
         else:
-            ext = in_stream.split('/')[-1][i_ext+1:]
-            if ext == 'fcio':
-                in_stream_type = 'FlashCam'
+            ext = in_stream.split("/")[-1][i_ext + 1 :]
+            if ext == "fcio":
+                in_stream_type = "FlashCam"
             elif OrcaStreamer.is_orca_stream(in_stream):
-                in_stream_type = 'ORCA'
+                in_stream_type = "ORCA"
             else:
-                raise RuntimeError(f'unknown file extension {ext}. Specify in_stream_type')
+                raise RuntimeError(
+                    f"unknown file extension {ext}. Specify in_stream_type"
+                )
 
-    # procss out_spec and setup rb_lib if specified
+    # process out_spec and setup rb_lib if specified
     rb_lib = None
-    if isinstance(out_spec, str) and out_spec.endswith('.json'):
-        with open(out_spec) as json_file: out_spec = json.load(json_file)
+    if isinstance(out_spec, str) and out_spec.endswith(".json"):
+        with open(out_spec) as json_file:
+            out_spec = json.load(json_file)
     if isinstance(out_spec, dict):
         out_spec = RawBufferLibrary(json_dict=out_spec, kw_dict=kwargs)
-    if isinstance(out_spec, RawBufferLibrary): rb_lib = out_spec
+    if isinstance(out_spec, RawBufferLibrary):
+        rb_lib = out_spec
     # if no rb_lib, write all data to file
     if out_spec is None:
         out_spec = in_stream
-        i_ext = out_spec.rfind('.')
-        if i_ext != -1: out_spec = out_spec[:i_ext]
-        out_spec += '.lh5'
+        i_ext = out_spec.rfind(".")
+        if i_ext != -1:
+            out_spec = out_spec[:i_ext]
+        out_spec += ".lh5"
     # by now, out_spec should be a str or a RawBufferLibrary
     if not isinstance(out_spec, str) and not isinstance(out_spec, RawBufferLibrary):
-        raise TypeError(f'unknown out_spec type {type(out_spec).__name__}')
+        raise TypeError(f"unknown out_spec type {type(out_spec).__name__}")
 
     # modify buffer_size if necessary for n_max
     if buffer_size < 1:
-        raise ValueError(f'bad buffer_size {buffer_size}')
-    if buffer_size > n_max: buffer_size = n_max
+        raise ValueError(f"bad buffer_size {buffer_size}")
+    if buffer_size > n_max:
+        buffer_size = n_max
 
-    log.info(f'input: {in_stream}')
+    log.info(f"input: {in_stream}")
     out_files = [out_spec]
     if isinstance(out_spec, RawBufferLibrary):
-        out_files = out_spec.get_list_of('out_stream')
-    if len(out_files) == 1: log.info(f'output: {out_files[0]}')
+        out_files = out_spec.get_list_of("out_stream")
+    if len(out_files) == 1:
+        log.info(f"output: {out_files[0]}")
     else:
-        log.info('output:')
-        for out_file in out_files: log.info(f' -> {out_file}')
-    log.info(f'buffer size: {buffer_size}')
+        log.info("output:")
+        for out_file in out_files:
+            log.info(f" -> {out_file}")
+    log.info(f"buffer size: {buffer_size}")
     if n_max < np.inf:
-        log.info(f'maximum number of events: {n_max}')
+        log.info(f"maximum number of events: {n_max}")
     if log.level <= 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="Processing", total=n_max, delay=2, unit="rows", file=sys.stdout
+            )
         else:
-            progress_bar = tqdm(desc='Processing', total=in_stream_size, delay=2,
-                                unit='B', unit_scale=True, file=sys.stdout)
+            progress_bar = tqdm(
+                desc="Processing",
+                total=in_stream_size,
+                delay=2,
+                unit="B",
+                unit_scale=True,
+                file=sys.stdout,
+            )
 
     # start a timer and a byte counter
     t_start = time.time()
 
     # select the appropriate streamer for in_stream
     streamer = None
-    if in_stream_type == 'ORCA':
+    if in_stream_type == "ORCA":
         streamer = OrcaStreamer()
-    elif in_stream_type == 'FlashCam':
+    elif in_stream_type == "FlashCam":
         streamer = FCStreamer()
-    elif in_stream_type == 'LlamaDaq':
-        raise NotImplementedError('LlamaDaq streaming not yet implemented')
-    elif in_stream_type == 'Compass':
-        raise NotImplementedError('Compass streaming not yet implemented')
-    elif in_stream_type == 'MGDO':
-        raise NotImplementedError('MGDO streaming not yet implemented')
+    elif in_stream_type == "LlamaDaq":
+        raise NotImplementedError("LlamaDaq streaming not yet implemented")
+    elif in_stream_type == "Compass":
+        raise NotImplementedError("Compass streaming not yet implemented")
+    elif in_stream_type == "MGDO":
+        raise NotImplementedError("MGDO streaming not yet implemented")
     else:
-        raise NotImplementedError('unknown input stream type {in_stream_type}')
+        raise NotImplementedError("unknown input stream type {in_stream_type}")
 
     # initialize the stream and read header. Also initializes rb_lib
     if log.level <= logging.INFO:
         progress_bar.update(0)
 
-    out_stream = out_spec if isinstance(out_spec, str) else ''
-    header_data = streamer.open_stream(in_stream, rb_lib=rb_lib, buffer_size=buffer_size,
-                                       chunk_mode='full_only', out_stream=out_stream)
+    out_stream = out_spec if isinstance(out_spec, str) else ""
+    header_data = streamer.open_stream(
+        in_stream,
+        rb_lib=rb_lib,
+        buffer_size=buffer_size,
+        chunk_mode="full_only",
+        out_stream=out_stream,
+    )
     rb_lib = streamer.rb_lib
     if log.level <= 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
     # overwritten or if we need to stop to avoid overwriting
-    out_files = rb_lib.get_list_of('out_stream')
+    out_files = rb_lib.get_list_of("out_stream")
     for out_file in out_files:
-        colpos = out_file.find(':')
-        if colpos != -1: out_file = out_file[:colpos]
+        colpos = out_file.find(":")
+        if colpos != -1:
+            out_file = out_file[:colpos]
         out_file_glob = glob.glob(out_file)
-        if len(out_file_glob) == 0: continue
+        if len(out_file_glob) == 0:
+            continue
         if len(out_file_glob) > 1:
-            raise RuntimeError(f'got multiple matches for out_file {out_file}: {out_file_glob}')
+            raise RuntimeError(
+                f"got multiple matches for out_file {out_file}: {out_file_glob}"
+            )
         if not overwrite:
-            raise RuntimeError(f'file {out_file_glob[0]} exists. Use option overwrite to proceed.')
+            raise FileExistsError(
+                f"file {out_file_glob[0]} exists. Use option overwrite to proceed."
+            )
 
         os.remove(out_file_glob[0])
 
@@ -184,25 +216,28 @@ def build_raw(in_stream: int, in_stream_type: str = None,
     while True:
         chunk_list = streamer.read_chunk()
         if log.level <= logging.INFO and n_max == np.inf:
-            progress_bar.update(streamer.n_bytes_read-n_bytes_last)
+            progress_bar.update(streamer.n_bytes_read - n_bytes_last)
             n_bytes_last = streamer.n_bytes_read
-        if len(chunk_list) == 0: break
+        if len(chunk_list) == 0:
+            break
         n_read = 0
         for rb in chunk_list:
-            if rb.loc > n_max: rb.loc = n_max
+            if rb.loc > n_max:
+                rb.loc = n_max
             n_max -= rb.loc
             n_read += rb.loc
         if log.level <= 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: break
+        if n_max <= 0:
+            break
 
     streamer.close_stream()
     progress_bar.close()
 
-    out_files = rb_lib.get_list_of('out_stream')
+    out_files = rb_lib.get_list_of("out_stream")
     if len(out_files) == 1:
-        out_file = out_files[0].split(':', 1)[0]
+        out_file = out_files[0].split(":", 1)[0]
         if os.path.exists(out_file):
             file_size = os.stat(out_file).st_size
             log.info(f"output file: {out_file} ({sizeof_fmt(file_size)})")
@@ -210,7 +245,7 @@ def build_raw(in_stream: int, in_stream_type: str = None,
             log.info(f"output file: {out_file} (not written)")
     else:
         log.info("output files:")
-        unique_outfilenames = {file.split(':', 1)[0] for file in out_files}
+        unique_outfilenames = {file.split(":", 1)[0] for file in out_files}
         for out_file in unique_outfilenames:
             if os.path.exists(out_file):
                 file_size = os.stat(out_file).st_size
diff --git a/src/pygama/raw/data_decoder.py b/src/pygama/raw/data_decoder.py
index b2395f362..72462712a 100644
--- a/src/pygama/raw/data_decoder.py
+++ b/src/pygama/raw/data_decoder.py
@@ -40,17 +40,23 @@ class DataDecoder(ABC):
     call :meth:`.put_in_garbage`. User should set up an enum or bitbank of garbage
     codes to be stored along with the garbage packets.
     """
-    def __init__(self, garbage_length: int = 256, packet_size_guess: int = 1024) -> None:
+
+    def __init__(
+        self, garbage_length: int = 256, packet_size_guess: int = 1024
+    ) -> None:
         self.garbage_table = lgdo.Table(garbage_length)
         shape_guess = (garbage_length, packet_size_guess)
-        self.garbage_table.add_field('packets',
-                                     lgdo.VectorOfVectors(shape_guess=shape_guess, dtype='uint8'))
-        self.garbage_table.add_field('packet_id',
-                                     lgdo.Array(shape=garbage_length, dtype='uint32'))
+        self.garbage_table.add_field(
+            "packets", lgdo.VectorOfVectors(shape_guess=shape_guess, dtype="uint8")
+        )
+        self.garbage_table.add_field(
+            "packet_id", lgdo.Array(shape=garbage_length, dtype="uint32")
+        )
         # TODO: add garbage codes enum attribute: user supplies in constructor
         # before calling super()
-        self.garbage_table.add_field('garbage_code',
-                                     lgdo.Array(shape=garbage_length, dtype='uint32'))
+        self.garbage_table.add_field(
+            "garbage_code", lgdo.Array(shape=garbage_length, dtype="uint32")
+        )
 
     def get_key_list(self) -> list[int | str]:
         """Overload with list of keys for this decoder, e.g. ``return
@@ -69,11 +75,12 @@ def get_decoded_values(self, key: int | str = None) -> dict:
         which should be defined in the constructor.
         """
         if key is None:
-            return self.decoded_values if hasattr(self, 'decoded_values') else None
+            return self.decoded_values if hasattr(self, "decoded_values") else None
 
         raise NotImplementedError(
             "you need to implement key-specific get_decoded_values for",
-            type(self).__name__)
+            type(self).__name__,
+        )
 
     def make_lgdo(self, key: int | str = None, size: int = None) -> LGDO:
         """Make an LGDO for this :class:`DataDecoder` to fill.
@@ -99,8 +106,10 @@ def make_lgdo(self, key: int | str = None, size: int = None) -> LGDO:
             the newly allocated LGDO.
         """
 
-        if not hasattr(self, 'decoded_values'):
-            raise AttributeError(type(self).__name__, ':no decoded_values available for setting up table')
+        if not hasattr(self, "decoded_values"):
+            raise AttributeError(
+                type(self).__name__, ":no decoded_values available for setting up table"
+            )
 
         data_obj = lgdo.Table(size=size)
         dec_vals = self.get_decoded_values(key)
@@ -110,33 +119,42 @@ def make_lgdo(self, key: int | str = None, size: int = None) -> LGDO:
             attrs = fld_attrs.copy()
 
             # get the dtype
-            if 'dtype' not in attrs:
-                raise AttributeError(type(self).__name__, ': must specify dtype for', field)
+            if "dtype" not in attrs:
+                raise AttributeError(
+                    type(self).__name__, ": must specify dtype for", field
+                )
 
-            dtype = attrs.pop('dtype')
+            dtype = attrs.pop("dtype")
 
             # no datatype: just a "normal" array
-            if 'datatype' not in attrs:
+            if "datatype" not in attrs:
                 # allow to override "kind" for the dtype for lgdo
-                if 'kind' in attrs:
-                    attrs['datatype'] = 'array<1>{' + attrs.pop('kind') + '}'
-                data_obj.add_field(field, lgdo.Array(shape=size, dtype=dtype, attrs=attrs))
+                if "kind" in attrs:
+                    attrs["datatype"] = "array<1>{" + attrs.pop("kind") + "}"
+                data_obj.add_field(
+                    field, lgdo.Array(shape=size, dtype=dtype, attrs=attrs)
+                )
                 continue
 
             # get datatype for complex objects
-            datatype = attrs.pop('datatype')
+            datatype = attrs.pop("datatype")
 
             # waveforms: must have attributes t0_units, dt, dt_units, wf_len
-            if datatype == 'waveform':
-                t0_units = attrs.pop('t0_units')
-                dt = attrs.pop('dt')
-                dt_units = attrs.pop('dt_units')
-                wf_len = attrs.pop('wf_len')
-                wf_table = lgdo.WaveformTable(size=size,
-                                              t0=0, t0_units=t0_units,
-                                              dt=dt, dt_units=dt_units,
-                                              wf_len=wf_len, dtype=dtype,
-                                              attrs=attrs)
+            if datatype == "waveform":
+                t0_units = attrs.pop("t0_units")
+                dt = attrs.pop("dt")
+                dt_units = attrs.pop("dt_units")
+                wf_len = attrs.pop("wf_len")
+                wf_table = lgdo.WaveformTable(
+                    size=size,
+                    t0=0,
+                    t0_units=t0_units,
+                    dt=dt,
+                    dt_units=dt_units,
+                    wf_len=wf_len,
+                    dtype=dtype,
+                    attrs=attrs,
+                )
                 data_obj.add_field(field, wf_table)
                 continue
 
@@ -144,43 +162,59 @@ def make_lgdo(self, key: int | str = None, size: int = None) -> LGDO:
             datatype, shape, elements = lgdo.lgdo_utils.parse_datatype(datatype)
 
             # ArrayOfEqualSizedArrays
-            if datatype == 'array_of_equalsized_arrays':
-                length = attrs.pop('length')
+            if datatype == "array_of_equalsized_arrays":
+                length = attrs.pop("length")
                 # only arrays of 1D arrays are supported at present
-                dims = (1,1)
-                aoesa = lgdo.ArrayOfEqualSizedArrays(shape=(size,length), dtype=dtype, dims=dims, attrs=attrs)
+                dims = (1, 1)
+                aoesa = lgdo.ArrayOfEqualSizedArrays(
+                    shape=(size, length), dtype=dtype, dims=dims, attrs=attrs
+                )
                 data_obj.add_field(field, aoesa)
                 continue
 
             # VectorOfVectors
-            if elements.startswith('array'):
+            if elements.startswith("array"):
                 length_guess = size
-                if 'length_guess' in attrs: length_guess = attrs.pop('length_guess')
-                vov = lgdo.VectorOfVectors(shape_guess=(size,length_guess), dtype=dtype, attrs=attrs)
+                if "length_guess" in attrs:
+                    length_guess = attrs.pop("length_guess")
+                vov = lgdo.VectorOfVectors(
+                    shape_guess=(size, length_guess), dtype=dtype, attrs=attrs
+                )
                 data_obj.add_field(field, vov)
                 continue
 
             # if we get here, got a bad datatype
-            raise RuntimeError(type(self).__name__, ': do not know how to make a', datatype, 'for', field)
+            raise RuntimeError(
+                type(self).__name__,
+                ": do not know how to make a",
+                datatype,
+                "for",
+                field,
+            )
 
         return data_obj
 
     def put_in_garbage(self, packet: int, packet_id: int, code: int) -> None:
         i_row = self.garbage_table.loc
-        p8 = np.frombuffer(packet, dtype='uint8')
-        self.garbage_table['packets'].set_vector(i_row, p8)
-        self.garbage_table['packet_id'].nda[i_row] = packet_id
-        self.garbage_table['garbage_codes'].nda[i_row] = code
+        p8 = np.frombuffer(packet, dtype="uint8")
+        self.garbage_table["packets"].set_vector(i_row, p8)
+        self.garbage_table["packet_id"].nda[i_row] = packet_id
+        self.garbage_table["garbage_codes"].nda[i_row] = code
         self.garbage_table.push_row()
 
-    def write_out_garbage(self, filename: str, group: str = '/', lh5_store: LH5Store = None) -> None:
-        if lh5_store is None: lh5_store = lgdo.LH5Store()
+    def write_out_garbage(
+        self, filename: str, group: str = "/", lh5_store: LH5Store = None
+    ) -> None:
+        if lh5_store is None:
+            lh5_store = lgdo.LH5Store()
         n_rows = self.garbage_table.loc
-        if n_rows == 0: return
-        lh5_store.write_object(self.garbage_table, 'garbage', filename, group, n_rows=n_rows, append=True)
+        if n_rows == 0:
+            return
+        lh5_store.write_object(
+            self.garbage_table, "garbage", filename, group, n_rows=n_rows, append=True
+        )
         self.garbage_table.clear()
 
-
     def get_max_rows_in_packet(self) -> int:
         """Returns the maximum number of rows that could be read out in a
         packet.
diff --git a/src/pygama/raw/data_streamer.py b/src/pygama/raw/data_streamer.py
index 445802dc3..8f271ec0d 100644
--- a/src/pygama/raw/data_streamer.py
+++ b/src/pygama/raw/data_streamer.py
@@ -33,9 +33,14 @@ def __init__(self) -> None:
         self.any_full = False
         self.packet_id = 0
 
-    def open_stream(self, stream_name: str, rb_lib: RawBufferLibrary = None,
-                    buffer_size: int = 8192, chunk_mode: str = 'any_full',
-                    out_stream: str = '') -> tuple[list[RawBuffer], int]:
+    def open_stream(
+        self,
+        stream_name: str,
+        rb_lib: RawBufferLibrary = None,
+        buffer_size: int = 8192,
+        chunk_mode: str = "any_full",
+        out_stream: str = "",
+    ) -> tuple[list[RawBuffer], int]:
         r"""Open and initialize a data stream.
 
         Open the stream, read in the header, set up the buffers.
@@ -80,7 +85,8 @@ def open_stream(self, stream_name: str, rb_lib: RawBufferLibrary = None,
         self.chunk_mode = chunk_mode
 
         # prepare rb_lib -- its lgdo's should still be uninitialized
-        if rb_lib is None: rb_lib = self.build_default_rb_lib(out_stream=out_stream)
+        if rb_lib is None:
+            rb_lib = self.build_default_rb_lib(out_stream=out_stream)
         self.rb_lib = rb_lib
 
         # now initialize lgdo's for raw buffers
@@ -91,14 +97,18 @@ def open_stream(self, stream_name: str, rb_lib: RawBufferLibrary = None,
 
             # set up wildcard decoder buffers
             if dec_name not in rb_lib:
-                if '*' not in rb_lib: continue # user didn't want this decoder
+                if "*" not in rb_lib:
+                    continue  # user didn't want this decoder
                 rb_lib[dec_name] = RawBufferList()
                 dec_key = dec_name
-                if dec_key.endswith('Decoder'): dec_key = dec_key.removesuffix('Decoder')
-                out_name = rb_lib['*'][0].out_name.format(name=dec_key)
-                out_stream = rb_lib['*'][0].out_stream.format(name=dec_key)
+                if dec_key.endswith("Decoder"):
+                    dec_key = dec_key.removesuffix("Decoder")
+                out_name = rb_lib["*"][0].out_name.format(name=dec_key)
+                out_stream = rb_lib["*"][0].out_stream.format(name=dec_key)
                 key_list = decoder.get_key_list()
-                rb = RawBuffer(key_list=key_list, out_stream=out_stream, out_name=out_name)
+                rb = RawBuffer(
+                    key_list=key_list, out_stream=out_stream, out_name=out_name
+                )
                 rb_lib[dec_name].append(rb)
 
             # dec_name is in rb_lib: store the name, and initialize its buffer lgdos
@@ -111,13 +121,16 @@ def open_stream(self, stream_name: str, rb_lib: RawBufferLibrary = None,
             keyed_name_rbs = []
             ii = 0
             while ii < len(rb_lib[dec_name]):
-                if '{key' in rb_lib[dec_name][ii].out_name:
+                if "{key" in rb_lib[dec_name][ii].out_name:
                     keyed_name_rbs.append(rb_lib[dec_name].pop(ii))
-                else: ii += 1
+                else:
+                    ii += 1
             for rb in keyed_name_rbs:
                 for key in rb.key_list:
                     expanded_name = rb.out_name.format(key=key)
-                    new_rb = RawBuffer(key_list=[key], out_stream=rb.out_stream, out_name=expanded_name)
+                    new_rb = RawBuffer(
+                        key_list=[key], out_stream=rb.out_stream, out_name=expanded_name
+                    )
                     rb_lib[dec_name].append(new_rb)
 
             for rb in rb_lib[dec_name]:
@@ -127,10 +140,11 @@ def open_stream(self, stream_name: str, rb_lib: RawBufferLibrary = None,
 
         # make sure there were no entries in rb_lib that weren't among the
         # decoders. If so, just emit a warning and continue.
-        if '*' in rb_lib: rb_lib.pop('*')
+        if "*" in rb_lib:
+            rb_lib.pop("*")
         for dec_name in rb_lib.keys():
             if dec_name not in dec_names:
-                log.warning("no decoder named {dec_name} requested by rb_lib")
+                log.warning(f"no decoder named '{dec_name}' requested by rb_lib")
 
     def close_stream(self) -> None:
         """Close this data stream.
@@ -155,8 +169,12 @@ def read_packet(self) -> bool:
         """
         return True
 
-    def read_chunk(self, chunk_mode_override: str = None, rp_max: int = 1000000,
-                   clear_full_buffers: bool = True) -> tuple[list[RawBuffer], int]:
+    def read_chunk(
+        self,
+        chunk_mode_override: str = None,
+        rp_max: int = 1000000,
+        clear_full_buffers: bool = True,
+    ) -> tuple[list[RawBuffer], int]:
         """Reads a chunk of data into raw buffers.
 
         Reads packets until at least one buffer is too full to perform another
@@ -197,32 +215,41 @@ def read_chunk(self, chunk_mode_override: str = None, rp_max: int = 1000000,
             not all have the same structure
         """
 
-        if clear_full_buffers: self.rb_lib.clear_full()
+        if clear_full_buffers:
+            self.rb_lib.clear_full()
         self.any_full = False
 
-        chunk_mode = self.chunk_mode if chunk_mode_override is None else chunk_mode_override
+        chunk_mode = (
+            self.chunk_mode if chunk_mode_override is None else chunk_mode_override
+        )
 
-        read_one_packet = (chunk_mode == 'single_packet')
-        only_full = (chunk_mode == 'only_full')
+        read_one_packet = chunk_mode == "single_packet"
+        only_full = chunk_mode == "only_full"
 
         n_packets = 0
         still_has_data = True
         while True:
             still_has_data = self.read_packet()
-            if not still_has_data: break
+            if not still_has_data:
+                break
             n_packets += 1
-            if read_one_packet or n_packets > rp_max: break
-            if self.any_full: break
+            if read_one_packet or n_packets > rp_max:
+                break
+            if self.any_full:
+                break
 
         # send back all rb's with data if we finished reading
-        if not still_has_data: only_full = False
+        if not still_has_data:
+            only_full = False
 
         list_of_rbs = []
         for rb_list in self.rb_lib.values():
             for rb in rb_list:
-                if not only_full: # any_full or read_one_packet
-                    if rb.loc > 0: list_of_rbs.append(rb)
-                elif rb.is_full(): list_of_rbs.append(rb)
+                if not only_full:  # any_full or read_one_packet
+                    if rb.loc > 0:
+                        list_of_rbs.append(rb)
+                elif rb.is_full():
+                    list_of_rbs.append(rb)
         return list_of_rbs
 
     def get_decoder_list(self) -> list:
@@ -234,7 +261,7 @@ def get_decoder_list(self) -> list:
         """
         return []
 
-    def build_default_rb_lib(self, out_stream: str = '') -> RawBufferLibrary:
+    def build_default_rb_lib(self, out_stream: str = "") -> RawBufferLibrary:
         """Build the most basic :class:`~.RawBufferLibrary` that will work for
         this stream.
 
@@ -246,12 +273,15 @@ def build_default_rb_lib(self, out_stream: str = '') -> RawBufferLibrary:
         rb_lib = RawBufferLibrary()
         decoders = self.get_decoder_list()
         if len(decoders) == 0:
-            log.warning(f'no decoders returned by get_decoder_list() for {type(self).__name__}')
+            log.warning(
+                f"no decoders returned by get_decoder_list() for {type(self).__name__}"
+            )
             return rb_lib
         for decoder in decoders:
             dec_name = type(decoder).__name__
             dec_key = dec_name
-            if dec_key.endswith('Decoder'): dec_key = dec_key.removesuffix('Decoder')
+            if dec_key.endswith("Decoder"):
+                dec_key = dec_key.removesuffix("Decoder")
             key_list = decoder.get_key_list()
             rb = RawBuffer(key_list=key_list, out_stream=out_stream, out_name=dec_key)
             rb_lib[dec_name] = RawBufferList()
diff --git a/src/pygama/raw/fc/fc_config_decoder.py b/src/pygama/raw/fc/fc_config_decoder.py
index 1dd1d5c71..e48e77e53 100644
--- a/src/pygama/raw/fc/fc_config_decoder.py
+++ b/src/pygama/raw/fc/fc_config_decoder.py
@@ -31,29 +31,30 @@ class FCConfigDecoder(DataDecoder):
     >>> type(config)
     pygama.lgdo.struct.Struct
     """
+
     def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
         self.config = lgdo.Struct()
 
     def decode_config(self, fcio: fcutils.fcio) -> lgdo.Struct:
         config_names = [
-            'nsamples', # samples per channel
-            'nadcs', # number of adc channels
-            'ntriggers', # number of triggertraces
-            'telid', # id of telescope
-            'adcbits', # bit range of the adc channels
-            'sumlength', # length of the fpga integrator
-            'blprecision', # precision of the fpga baseline
-            'mastercards', # number of attached mastercards
-            'triggercards', # number of attached triggercards
-            'adccards', # number of attached fadccards
-            'gps', # gps mode (0: not used, 1: external pps and 10MHz)
+            "nsamples",  # samples per channel
+            "nadcs",  # number of adc channels
+            "ntriggers",  # number of triggertraces
+            "telid",  # id of telescope
+            "adcbits",  # bit range of the adc channels
+            "sumlength",  # length of the fpga integrator
+            "blprecision",  # precision of the fpga baseline
+            "mastercards",  # number of attached mastercards
+            "triggercards",  # number of attached triggercards
+            "adccards",  # number of attached fadccards
+            "gps",  # gps mode (0: not used, 1: external pps and 10MHz)
         ]
         for name in config_names:
             if name in self.config:
-                log.warning(f'{name} already in self.config. skipping...')
+                log.warning(f"{name} already in self.config. skipping...")
                 continue
-            value = np.int32(getattr(fcio, name)) # all config fields are int32
+            value = np.int32(getattr(fcio, name))  # all config fields are int32
             self.config.add_field(name, lgdo.Scalar(value))
         return self.config
 
diff --git a/src/pygama/raw/fc/fc_event_decoder.py b/src/pygama/raw/fc/fc_event_decoder.py
index 6e0fd94e9..270234419 100644
--- a/src/pygama/raw/fc/fc_event_decoder.py
+++ b/src/pygama/raw/fc/fc_event_decoder.py
@@ -14,125 +14,127 @@
 # put decoded values here where they can be used also by the orca decoder
 fc_decoded_values = {
     # packet index in file
-    'packet_id': {
-       'dtype': 'uint32',
-     },
+    "packet_id": {
+        "dtype": "uint32",
+    },
     # index of event
-    'eventnumber': {
-      'dtype': 'int32',
+    "eventnumber": {
+        "dtype": "int32",
     },
     # time since epoch
-    'timestamp': {
-      'dtype': 'float64',
-      'units': 's',
+    "timestamp": {
+        "dtype": "float64",
+        "units": "s",
     },
     # time since beginning of file
-    'runtime': {
-      'dtype': 'float64',
-      'units': 's',
+    "runtime": {
+        "dtype": "float64",
+        "units": "s",
     },
     # number of triggered adc channels
-    'numtraces': {
-      'dtype': 'int32',
+    "numtraces": {
+        "dtype": "int32",
     },
     # list of triggered adc channels
-    'tracelist': {
-      'dtype': 'int16',
-      'datatype': 'array<1>{array<1>{real}}', # vector of vectors
-      'length_guess': 16,
+    "tracelist": {
+        "dtype": "int16",
+        "datatype": "array<1>{array<1>{real}}",  # vector of vectors
+        "length_guess": 16,
     },
     # fpga baseline
-    'baseline': {
-      'dtype': 'uint16',
+    "baseline": {
+        "dtype": "uint16",
     },
     # fpga energy
-    'daqenergy': {
-      'dtype': 'uint16',
+    "daqenergy": {
+        "dtype": "uint16",
     },
     # right now, index of the trigger (trace)
-    'channel': {
-      'dtype': 'uint32',
+    "channel": {
+        "dtype": "uint32",
     },
     # PPS timestamp in sec
-    'ts_pps': {
-      'dtype': 'int32',
+    "ts_pps": {
+        "dtype": "int32",
     },
     # clock ticks
-    'ts_ticks': {
-      'dtype': 'int32',
+    "ts_ticks": {
+        "dtype": "int32",
     },
     # max clock ticks
-    'ts_maxticks': {
-      'dtype': 'int32',
+    "ts_maxticks": {
+        "dtype": "int32",
     },
     # the offset in sec between the master and unix
-    'to_mu_sec': {
-      'dtype': 'int64',
+    "to_mu_sec": {
+        "dtype": "int64",
     },
     # the offset in usec between master and unix
-    'to_mu_usec': {
-      'dtype': 'int32',
+    "to_mu_usec": {
+        "dtype": "int32",
     },
     # the calculated sec which must be added to the master
-    'to_master_sec': {
-      'dtype': 'int64',
+    "to_master_sec": {
+        "dtype": "int64",
     },
     # the delta time between master and unix in usec
-    'to_dt_mu_usec': {
-      'dtype': 'int32',
+    "to_dt_mu_usec": {
+        "dtype": "int32",
     },
     # the abs(time) between master and unix in usec
-    'to_abs_mu_usec': {
-      'dtype': 'int32',
+    "to_abs_mu_usec": {
+        "dtype": "int32",
     },
     # startsec
-    'to_start_sec': {
-      'dtype': 'int64',
+    "to_start_sec": {
+        "dtype": "int64",
     },
     # startusec
-    'to_start_usec': {
-      'dtype': 'int32',
+    "to_start_usec": {
+        "dtype": "int32",
     },
     # start pps of the next dead window
-    'dr_start_pps': {
-      'dtype': 'int32',
+    "dr_start_pps": {
+        "dtype": "int32",
     },
     # start ticks of the next dead window
-    'dr_start_ticks': {
-      'dtype': 'int32',
+    "dr_start_ticks": {
+        "dtype": "int32",
     },
     # stop pps of the next dead window
-    'dr_stop_pps': {
-      'dtype': 'int32',
+    "dr_stop_pps": {
+        "dtype": "int32",
     },
     # stop ticks of the next dead window
-    'dr_stop_ticks': {
-      'dtype': 'int32',
+    "dr_stop_ticks": {
+        "dtype": "int32",
     },
     # maxticks of the dead window
-    'dr_maxticks': {
-      'dtype': 'int32',
+    "dr_maxticks": {
+        "dtype": "int32",
     },
     # current dead time calculated from deadregion (dr) fields.
     # Give the total dead time if summed up.
-    'deadtime': {
-      'dtype': 'float64',
+    "deadtime": {
+        "dtype": "float64",
     },
     # waveform data
-    'waveform': {
-      'dtype': 'uint16',
-      'datatype': 'waveform',
-      'wf_len': 65532, # max value. override this before initializing buffers to save RAM
-      'dt': 16, # override if a different clock rate is used
-      'dt_units': 'ns',
-      't0_units': 'ns',
+    "waveform": {
+        "dtype": "uint16",
+        "datatype": "waveform",
+        "wf_len": 65532,  # max value. override this before initializing buffers to save RAM
+        "dt": 16,  # override if a different clock rate is used
+        "dt_units": "ns",
+        "t0_units": "ns",
     },
 }
 
+
 class FCEventDecoder(DataDecoder):
     """
     Decode FlashCam digitizer event data.
     """
+
     def __init__(self, *args, **kwargs) -> None:
         # these are read for every event (decode_event)
         self.decoded_values = copy.deepcopy(fc_decoded_values)
@@ -142,7 +144,7 @@ def __init__(self, *args, **kwargs) -> None:
         self.max_numtraces = 1
 
     def get_key_list(self) -> range:
-        return range(self.fc_config['nadcs'].value)
+        return range(self.fc_config["nadcs"].value)
 
     def get_decoded_values(self, channel: int = None) -> dict[str, dict[str, Any]]:
         # FC uses the same values for all channels
@@ -157,11 +159,14 @@ def set_file_config(self, fc_config: lgdo.Struct) -> None:
             extracted via :meth:`~.fc_config_decoder.FCConfigDecoder.decode_config`.
         """
         self.fc_config = fc_config
-        self.decoded_values['waveform']['wf_len'] = self.fc_config['nsamples'].value
+        self.decoded_values["waveform"]["wf_len"] = self.fc_config["nsamples"].value
 
-    def decode_packet(self, fcio: fcutils.fcio,
-                      evt_rbkd: lgdo.Table | dict[int, lgdo.Table],
-                      packet_id: int) -> bool:
+    def decode_packet(
+        self,
+        fcio: fcutils.fcio,
+        evt_rbkd: lgdo.Table | dict[int, lgdo.Table],
+        packet_id: int,
+    ) -> bool:
         """Access ``FCIOEvent`` members for each event in the DAQ file.
 
         Parameters
@@ -186,7 +191,8 @@ def decode_packet(self, fcio: fcutils.fcio,
             self.max_numtraces = fcio.numtraces
             # The buffer might be storing all channels' data, so set the
             # fill_safety to the max number of traces we've seen so far.
-            for rb in evt_rbkd.values(): rb.fill_safety = self.max_numtraces
+            for rb in evt_rbkd.values():
+                rb.fill_safety = self.max_numtraces
         any_full = False
 
         # a list of channels is read out simultaneously for each event
@@ -194,45 +200,58 @@ def decode_packet(self, fcio: fcutils.fcio,
             if iwf not in evt_rbkd:
                 if iwf not in self.skipped_channels:
                     # TODO: should this be a warning instead?
-                    log.debug(f'skipping packets from channel {iwf}...')
+                    log.debug(f"skipping packets from channel {iwf}...")
                     self.skipped_channels[iwf] = 0
                 self.skipped_channels[iwf] += 1
                 continue
             tbl = evt_rbkd[iwf].lgdo
-            if fcio.nsamples != tbl['waveform']['values'].nda.shape[1]:
-                log.warning('event wf length was', fcio.nsamples, 'when',
-                            self.decoded_values['waveform']['wf_len'], 'were expected')
+            if fcio.nsamples != tbl["waveform"]["values"].nda.shape[1]:
+                log.warning(
+                    "event wf length was",
+                    fcio.nsamples,
+                    "when",
+                    self.decoded_values["waveform"]["wf_len"],
+                    "were expected",
+                )
             ii = evt_rbkd[iwf].loc
 
             # fill the table
-            tbl['channel'].nda[ii] = iwf
-            tbl['packet_id'].nda[ii] = packet_id
-            tbl['eventnumber'].nda[ii] = fcio.eventnumber # the eventnumber since the beginning of the file
-            tbl['timestamp'].nda[ii] = fcio.eventtime # the time since epoch in seconds
-            tbl['runtime'].nda[ii] = fcio.runtime # the time since the beginning of the file in seconds
-            tbl['numtraces'].nda[ii] = fcio.numtraces # number of triggered adcs
-            tbl['tracelist'].set_vector(ii, fcio.tracelist) # list of triggered adcs
-            tbl['baseline'].nda[ii] = fcio.baseline[iwf] # the fpga baseline values for each channel in LSB
-            tbl['daqenergy'].nda[ii] = fcio.daqenergy[iwf] # the fpga energy values for each channel in LSB
-            tbl['ts_pps'].nda[ii] = fcio.timestamp_pps
-            tbl['ts_ticks'].nda[ii] = fcio.timestamp_ticks
-            tbl['ts_maxticks'].nda[ii] = fcio.timestamp_maxticks
-            tbl['to_mu_sec'].nda[ii] = fcio.timeoffset_mu_sec
-            tbl['to_mu_usec'].nda[ii] = fcio.timeoffset_mu_usec
-            tbl['to_master_sec'].nda[ii] = fcio.timeoffset_master_sec
-            tbl['to_dt_mu_usec'].nda[ii] = fcio.timeoffset_dt_mu_usec
-            tbl['to_abs_mu_usec'].nda[ii] = fcio.timeoffset_abs_mu_usec
-            tbl['to_start_sec'].nda[ii] = fcio.timeoffset_start_sec
-            tbl['to_start_usec'].nda[ii] = fcio.timeoffset_start_usec
-            tbl['dr_start_pps'].nda[ii] = fcio.deadregion_start_pps
-            tbl['dr_start_ticks'].nda[ii] = fcio.deadregion_start_ticks
-            tbl['dr_stop_pps'].nda[ii] = fcio.deadregion_stop_pps
-            tbl['dr_stop_ticks'].nda[ii] = fcio.deadregion_stop_ticks
-            tbl['dr_maxticks'].nda[ii] = fcio.deadregion_maxticks
-            tbl['deadtime'].nda[ii] = fcio.deadtime
-
-            #if len(traces[iwf]) != fcio.nsamples: # number of sample per trace check
-            tbl['waveform']['values'].nda[ii][:] = fcio.traces[iwf]
+            tbl["channel"].nda[ii] = iwf
+            tbl["packet_id"].nda[ii] = packet_id
+            tbl["eventnumber"].nda[
+                ii
+            ] = fcio.eventnumber  # the eventnumber since the beginning of the file
+            tbl["timestamp"].nda[ii] = fcio.eventtime  # the time since epoch in seconds
+            tbl["runtime"].nda[
+                ii
+            ] = fcio.runtime  # the time since the beginning of the file in seconds
+            tbl["numtraces"].nda[ii] = fcio.numtraces  # number of triggered adcs
+            tbl["tracelist"].set_vector(ii, fcio.tracelist)  # list of triggered adcs
+            tbl["baseline"].nda[ii] = fcio.baseline[
+                iwf
+            ]  # the fpga baseline values for each channel in LSB
+            tbl["daqenergy"].nda[ii] = fcio.daqenergy[
+                iwf
+            ]  # the fpga energy values for each channel in LSB
+            tbl["ts_pps"].nda[ii] = fcio.timestamp_pps
+            tbl["ts_ticks"].nda[ii] = fcio.timestamp_ticks
+            tbl["ts_maxticks"].nda[ii] = fcio.timestamp_maxticks
+            tbl["to_mu_sec"].nda[ii] = fcio.timeoffset_mu_sec
+            tbl["to_mu_usec"].nda[ii] = fcio.timeoffset_mu_usec
+            tbl["to_master_sec"].nda[ii] = fcio.timeoffset_master_sec
+            tbl["to_dt_mu_usec"].nda[ii] = fcio.timeoffset_dt_mu_usec
+            tbl["to_abs_mu_usec"].nda[ii] = fcio.timeoffset_abs_mu_usec
+            tbl["to_start_sec"].nda[ii] = fcio.timeoffset_start_sec
+            tbl["to_start_usec"].nda[ii] = fcio.timeoffset_start_usec
+            tbl["dr_start_pps"].nda[ii] = fcio.deadregion_start_pps
+            tbl["dr_start_ticks"].nda[ii] = fcio.deadregion_start_ticks
+            tbl["dr_stop_pps"].nda[ii] = fcio.deadregion_stop_pps
+            tbl["dr_stop_ticks"].nda[ii] = fcio.deadregion_stop_ticks
+            tbl["dr_maxticks"].nda[ii] = fcio.deadregion_maxticks
+            tbl["deadtime"].nda[ii] = fcio.deadtime
+
+            # if len(traces[iwf]) != fcio.nsamples: # number of sample per trace check
+            tbl["waveform"]["values"].nda[ii][:] = fcio.traces[iwf]
 
             evt_rbkd[iwf].loc += 1
             any_full |= evt_rbkd[iwf].is_full()
diff --git a/src/pygama/raw/fc/fc_status_decoder.py b/src/pygama/raw/fc/fc_status_decoder.py
index 15176934e..76d4378da 100644
--- a/src/pygama/raw/fc/fc_status_decoder.py
+++ b/src/pygama/raw/fc/fc_status_decoder.py
@@ -9,89 +9,92 @@ class FCStatusDecoder(DataDecoder):
     """
     Decode FlashCam digitizer status data.
     """
+
     def __init__(self, *args, **kwargs) -> None:
         self.decoded_values = {
-            'status': { # 0: Errors occurred, 1: no errors
-              'dtype': 'int32',
+            "status": {  # 0: Errors occurred, 1: no errors
+                "dtype": "int32",
             },
-            'statustime': { # fc250 seconds, microseconds, dummy, startsec startusec
-              'dtype': 'float32',
-              'units': 's',
+            "statustime": {  # fc250 seconds, microseconds, dummy, startsec startusec
+                "dtype": "float32",
+                "units": "s",
             },
-            'cputime': { # CPU seconds, microseconds, dummy, startsec startusec
-              'dtype': 'float64',
-              'units': 's',
+            "cputime": {  # CPU seconds, microseconds, dummy, startsec startusec
+                "dtype": "float64",
+                "units": "s",
             },
-            'startoffset': { # fc250 seconds, microseconds, dummy, startsec startusec
-              'dtype': 'float32',
-              'units': 's',
+            "startoffset": {  # fc250 seconds, microseconds, dummy, startsec startusec
+                "dtype": "float32",
+                "units": "s",
             },
-            'cards': { # Total number of cards (number of status data to follow)
-              'dtype': 'int32',
+            "cards": {  # Total number of cards (number of status data to follow)
+                "dtype": "int32",
             },
-            'size': { # Size of each status data
-              'dtype': 'int32',
+            "size": {  # Size of each status data
+                "dtype": "int32",
             },
-            'environment': { # FC card-wise environment status
-              # Array contents:
-              # [0-4] Temps in mDeg
-              # [5-10] Voltages in mV
-              # 11 main current in mA
-              # 12 humidity in o/oo
-              # [13-14] Temps from adc cards in mDeg
-              # FIXME: change to a table?
-              'dtype': 'uint32',
-              'datatype': 'array_of_equalsized_arrays<1,1>{real}',
-              'length': 16,
+            "environment": {  # FC card-wise environment status
+                # Array contents:
+                # [0-4] Temps in mDeg
+                # [5-10] Voltages in mV
+                # 11 main current in mA
+                # 12 humidity in o/oo
+                # [13-14] Temps from adc cards in mDeg
+                # FIXME: change to a table?
+                "dtype": "uint32",
+                "datatype": "array_of_equalsized_arrays<1,1>{real}",
+                "length": 16,
             },
-            'totalerrors': { # FC card-wise list DAQ errors during data taking
-              'dtype': 'uint32',
+            "totalerrors": {  # FC card-wise list DAQ errors during data taking
+                "dtype": "uint32",
             },
-            'enverrors': {
-              'dtype': 'uint32',
+            "enverrors": {
+                "dtype": "uint32",
             },
-            'ctierrors': {
-              'dtype': 'uint32',
+            "ctierrors": {
+                "dtype": "uint32",
             },
-            'linkerrors': {
-              'dtype': 'uint32',
+            "linkerrors": {
+                "dtype": "uint32",
             },
-            'othererrors': {
-              'dtype': 'uint32',
-              'datatype': 'array_of_equalsized_arrays<1,1>{real}',
-              'length': 5,
+            "othererrors": {
+                "dtype": "uint32",
+                "datatype": "array_of_equalsized_arrays<1,1>{real}",
+                "length": 5,
             },
         }
         super().__init__(*args, **kwargs)
 
-    def decode_packet(self, fcio: fcutils.fcio, status_rb: RawBuffer, packet_id: int) -> bool:
+    def decode_packet(
+        self, fcio: fcutils.fcio, status_rb: RawBuffer, packet_id: int
+    ) -> bool:
         # aliases for brevity
         tbl = status_rb.lgdo
         ii = status_rb.loc
 
         # status -- 0: Errors occurred, 1: no errors
-        tbl['status'].nda[ii] = fcio.status
+        tbl["status"].nda[ii] = fcio.status
 
         # times
-        tbl['statustime'].nda[ii] = fcio.statustime[0]+fcio.statustime[1]/1e6
-        tbl['cputime'].nda[ii]    = fcio.statustime[2]+fcio.statustime[3]/1e6
-        tbl['startoffset'].nda[ii]= fcio.statustime[5]+fcio.statustime[6]/1e6
+        tbl["statustime"].nda[ii] = fcio.statustime[0] + fcio.statustime[1] / 1e6
+        tbl["cputime"].nda[ii] = fcio.statustime[2] + fcio.statustime[3] / 1e6
+        tbl["startoffset"].nda[ii] = fcio.statustime[5] + fcio.statustime[6] / 1e6
 
         # Total number of cards (number of status data to follow)
-        tbl['cards'].nda[ii] = fcio.cards
+        tbl["cards"].nda[ii] = fcio.cards
 
         # Size of each status data
-        tbl['size'].nda[ii] = fcio.size
+        tbl["size"].nda[ii] = fcio.size
 
         # FC card-wise environment status (temp., volt., hum., ...)
-        tbl['environment'].nda[ii][:] = fcio.environment
+        tbl["environment"].nda[ii][:] = fcio.environment
 
         # FC card-wise list DAQ errors during data taking
-        tbl['totalerrors'].nda[ii] = fcio.totalerrors
-        tbl['linkerrors'].nda[ii] = fcio.linkerrors
-        tbl['ctierrors'].nda[ii] = fcio.ctierrors
-        tbl['enverrors'].nda[ii] = fcio.enverrors
-        tbl['othererrors'].nda[ii][:] = fcio.othererrors
+        tbl["totalerrors"].nda[ii] = fcio.totalerrors
+        tbl["linkerrors"].nda[ii] = fcio.linkerrors
+        tbl["ctierrors"].nda[ii] = fcio.ctierrors
+        tbl["enverrors"].nda[ii] = fcio.enverrors
+        tbl["othererrors"].nda[ii][:] = fcio.othererrors
 
         status_rb.loc += 1
 
diff --git a/src/pygama/raw/fc/fc_streamer.py b/src/pygama/raw/fc/fc_streamer.py
index c8eafdbbe..2c4a273fd 100644
--- a/src/pygama/raw/fc/fc_streamer.py
+++ b/src/pygama/raw/fc/fc_streamer.py
@@ -19,6 +19,7 @@ class FCStreamer(DataStreamer):
     Decode FlashCam data, using the ``fcutils`` package to handle file access,
     and the FlashCam data decoders to save the results and write to output.
     """
+
     def __init__(self) -> None:
         super().__init__()
         self.fcio = None
@@ -36,9 +37,14 @@ def get_decoder_list(self) -> list[DataDecoder]:
         dec_list.append(self.event_decoder)
         return dec_list
 
-    def open_stream(self, fcio_filename: str, rb_lib: RawBufferLibrary = None,
-                    buffer_size: int = 8192, chunk_mode: str = 'any_full',
-                    out_stream: str = '') -> list[RawBuffer]:
+    def open_stream(
+        self,
+        fcio_filename: str,
+        rb_lib: RawBufferLibrary = None,
+        buffer_size: int = 8192,
+        chunk_mode: str = "any_full",
+        out_stream: str = "",
+    ) -> list[RawBuffer]:
         """Initialize the FlashCam data stream.
 
         Refer to the documentation for
@@ -55,59 +61,86 @@ def open_stream(self, fcio_filename: str, rb_lib: RawBufferLibrary = None,
         self.n_bytes_read = 0
 
         # read in file header (config) info
-        fc_config = self.config_decoder.decode_config(self.fcio) # returns an lgdo.Struct
+        fc_config = self.config_decoder.decode_config(
+            self.fcio
+        )  # returns an lgdo.Struct
         self.event_decoder.set_file_config(fc_config)
-        self.n_bytes_read += 11*4 # there are 11 ints in the fcio_config struct
+        self.n_bytes_read += 11 * 4  # there are 11 ints in the fcio_config struct
 
         # initialize the buffers in rb_lib. Store them for fast lookup
-        super().open_stream(fcio_filename, rb_lib, buffer_size=buffer_size,
-                            chunk_mode=chunk_mode, out_stream=out_stream)
-        if rb_lib is None: rb_lib = self.rb_lib
+        super().open_stream(
+            fcio_filename,
+            rb_lib,
+            buffer_size=buffer_size,
+            chunk_mode=chunk_mode,
+            out_stream=out_stream,
+        )
+        if rb_lib is None:
+            rb_lib = self.rb_lib
 
         # get the status rb_list and pull out its first element
-        status_rb_list = rb_lib['FCStatusDecoder'] if 'FCStatusDecoder' in rb_lib else None
+        status_rb_list = (
+            rb_lib["FCStatusDecoder"] if "FCStatusDecoder" in rb_lib else None
+        )
         if status_rb_list is not None:
             if len(status_rb_list) != 1:
-                log.warning(f'status_rb_list had length {len(status_rb_list)}, ignoring all but the first')
+                log.warning(
+                    f"status_rb_list had length {len(status_rb_list)}, ignoring all but the first"
+                )
             if len(status_rb_list) == 0:
                 self.status_rb = None
-            else: self.status_rb = status_rb_list[0]
-        self.event_rbkd = rb_lib['FCEventDecoder'].get_keyed_dict() if 'FCEventDecoder' in rb_lib else None
+            else:
+                self.status_rb = status_rb_list[0]
+        self.event_rbkd = (
+            rb_lib["FCEventDecoder"].get_keyed_dict()
+            if "FCEventDecoder" in rb_lib
+            else None
+        )
 
         # set up data loop variables
-        self.packet_id = 0 # for storing packet order in output tables
+        self.packet_id = 0  # for storing packet order in output tables
 
-        if 'FCConfigDecoder' in rb_lib:
-            config_rb_list = rb_lib['FCConfigDecoder']
+        if "FCConfigDecoder" in rb_lib:
+            config_rb_list = rb_lib["FCConfigDecoder"]
             if len(config_rb_list) != 1:
-                log.warning(f'config_rb_list had length {len(config_rb_list)}, ignoring all but the first')
+                log.warning(
+                    f"config_rb_list had length {len(config_rb_list)}, ignoring all but the first"
+                )
             rb = config_rb_list[0]
-        else: rb = RawBuffer(lgdo=fc_config)
-        rb.loc = 1 # we have filled this buffer
+        else:
+            rb = RawBuffer(lgdo=fc_config)
+        rb.loc = 1  # we have filled this buffer
         return [rb]
 
     def close_stream(self) -> None:
-        self.fcio = None # should cause close file in fcio.__dealloc__
+        self.fcio = None  # should cause close file in fcio.__dealloc__
 
     def read_packet(self) -> bool:
         rc = self.fcio.get_record()
-        if rc == 0: return False # no more data
+        if rc == 0:
+            return False  # no more data
 
         self.packet_id += 1
 
-        if rc == 1: # config (header) data
-            log.warning(f'got a header after start of run? n_bytes_read = {self.n_bytes_read}')
-            self.n_bytes_read += 11*4 # there are 11 ints in the fcio_config struct
+        if rc == 1:  # config (header) data
+            log.warning(
+                f"got a header after start of run? n_bytes_read = {self.n_bytes_read}"
+            )
+            self.n_bytes_read += 11 * 4  # there are 11 ints in the fcio_config struct
             return True
 
-        elif rc == 2: # calib record -- no longer supported
-            log.warning(f'warning: got a calib record? n_bytes_read = {self.n_bytes_read}')
+        elif rc == 2:  # calib record -- no longer supported
+            log.warning(
+                f"warning: got a calib record? n_bytes_read = {self.n_bytes_read}"
+            )
             return True
 
         # FIXME: push to a buffer of skipped packets?
         # FIXME: need to at least update n_bytes?
-        elif rc == 5: # recevent
-            log.warning(f'got a RecEvent packet -- skipping? n_bytes_read = {self.n_bytes_read}')
+        elif rc == 5:  # recevent
+            log.warning(
+                f"got a RecEvent packet -- skipping? n_bytes_read = {self.n_bytes_read}"
+            )
             # sizeof(fcio_recevent): (6 + 3*10 + 1*2304 + 3*4000)*4
             self.n_bytes_read += 57360
             return True
@@ -115,9 +148,9 @@ def read_packet(self) -> bool:
         # Status record
         elif rc == 4:
             if self.status_rb is not None:
-                self.any_full |= self.status_decoder.decode_packet(self.fcio,
-                                                                   self.status_rb,
-                                                                   self.packet_id)
+                self.any_full |= self.status_decoder.decode_packet(
+                    self.fcio, self.status_rb, self.packet_id
+                )
             # sizeof(fcio_status): (3 + 10 + 256*(10 + 9 + 16 + 4 + 256))*4
             self.n_bytes_read += 302132
             return True
@@ -125,9 +158,11 @@ def read_packet(self) -> bool:
         # Event or SparseEvent record
         elif rc == 3 or rc == 6:
             if self.event_rbkd is not None:
-                self.any_full |= self.event_decoder.decode_packet(self.fcio,
-                                                                  self.event_rbkd,
-                                                                  self.packet_id)
+                self.any_full |= self.event_decoder.decode_packet(
+                    self.fcio, self.event_rbkd, self.packet_id
+                )
             # sizeof(fcio_event): (5 + 3*10 + 1)*4 + numtraces*(1 + nsamples+2)*2
-            self.n_bytes_read += 144 + self.fcio.numtraces*(self.fcio.nsamples + 3)*2
+            self.n_bytes_read += (
+                144 + self.fcio.numtraces * (self.fcio.nsamples + 3) * 2
+            )
             return True
diff --git a/src/pygama/raw/orca/orca_base.py b/src/pygama/raw/orca/orca_base.py
index 35d143b20..e10c4759b 100644
--- a/src/pygama/raw/orca/orca_base.py
+++ b/src/pygama/raw/orca/orca_base.py
@@ -1,34 +1,39 @@
-from ..data_decoder import *
+from __future__ import annotations
+
+from pygama.raw.data_decoder import DataDecoder
+from pygama.raw.orca.orca_header import OrcaHeader
 
 
 class OrcaDecoder(DataDecoder):
-    """ Base class for ORCA decoders.
+    """Base class for ORCA decoders.
 
-    Mostly here to provide a standard interface for setting the header in init
+    Mostly here to provide a standard interface for setting the header during
+    initialization.
     """
-    def __init__(self, header=None, **kwargs):
+
+    def __init__(self, header: OrcaHeader = None, **kwargs) -> None:
         super().__init__(**kwargs)
         self.header = None
-        if header: self.set_header(header) # allow for derived class function to be called
-
+        if header:
+            self.set_header(header)  # allow for derived class function to be called
 
-    def set_header(self, header):
-        ''' Setter for headers. Overload to set card parameters, etc. '''
+    def set_header(self, header: OrcaHeader) -> None:
+        """Setter for headers. Overload to set card parameters, etc."""
         self.header = header
 
 
 # define a standard hash for crate, card, channel <--> integer
-def get_ccc(crate, card, channel):
-    return (crate << 9) + ((card & 0x1f) << 4) + (channel & 0xf)
+def get_ccc(crate: int, card: int, channel: int) -> int:
+    return (crate << 9) + ((card & 0x1F) << 4) + (channel & 0xF)
 
 
-def get_crate(ccc):
+def get_crate(ccc: int) -> int:
     return ccc >> 9
 
 
-def get_card(ccc):
-    return (ccc >> 4) & 0x1f
+def get_card(ccc: int) -> int:
+    return (ccc >> 4) & 0x1F
 
 
-def get_channel(ccc):
-    return ccc & 0xf
+def get_channel(ccc: int) -> int:
+    return ccc & 0xF
diff --git a/src/pygama/raw/orca/orca_digitizers.py b/src/pygama/raw/orca/orca_digitizers.py
index 465594727..e355a0738 100644
--- a/src/pygama/raw/orca/orca_digitizers.py
+++ b/src/pygama/raw/orca/orca_digitizers.py
@@ -1,648 +1,212 @@
+import copy
+import logging
 import sys
 
 import numpy as np
 
-from pygama.lgdo import Table
-
 from .orca_base import OrcaDecoder, get_ccc
 
+log = logging.getLogger(__name__)
+
 
-class ORCAStruck3302(OrcaDecoder):
+class ORSIS3302DecoderForEnergy(OrcaDecoder):
     """
-    ORCA decoder for Struck 3302 digitizer data
+    Decoder for Struck 3302 digitizer data written by ORCA
     """
-    def __init__(self, *args, **kwargs):
-
-        self.decoder_name = 'ORSIS3302DecoderForEnergy'
-        self.orca_class_name = 'ORSIS3302Model'
 
+    def __init__(self, header=None, **kwargs):
+        """
+        DOCME
+        """
         self.decoded_values_template = {
-            'packet_id': {
-               'dtype': 'uint32',
-             },
-            'ievt': {
-              'dtype': 'uint32',
+            "packet_id": {
+                "dtype": "uint32",
             },
-            'energy': {
-              'dtype': 'uint32',
-              'units': 'adc',
+            "energy": {
+                "dtype": "uint32",
+                "units": "adc",
             },
-            'energy_first': {
-              'dtype': 'uint32',
+            "energy_first": {
+                "dtype": "uint32",
             },
-            'timestamp': {
-              'dtype': 'uint64',
-              'units': 'clock_ticks',
+            "timestamp": {
+                "dtype": "uint64",
+                "units": "clock_ticks",
             },
-            'crate': {
-              'dtype': 'uint8',
+            "crate": {
+                "dtype": "uint8",
             },
-            'card': {
-              'dtype': 'uint8',
+            "card": {
+                "dtype": "uint8",
             },
-            'channel': {
-              'dtype': 'uint8',
+            "channel": {
+                "dtype": "uint8",
             },
-            'waveform': {
-              'dtype': 'uint16',
-              'datatype': 'waveform',
-              'length': 65532, # max value. override this before initializing buffers to save RAM
-              'sample_period': 10, # override if a different clock rate is used
-              'sample_period_units': 'ns',
-              'units': 'adc',
+            "waveform": {
+                "dtype": "uint16",
+                "datatype": "waveform",
+                "wf_len": 65532,  # max value. override this before initializing buffers to save RAM
+                "dt": 10,  # override if a different clock rate is used
+                "dt_units": "ns",
+                "t0_units": "ns",
             },
         }
-
-        super().__init__(*args, **kwargs) # also initializes the garbage df
-
         self.decoded_values = {}
-        self.ievt = 0
+        super().__init__(header=header, **kwargs)
         self.skipped_channels = {}
-        # self.enabled_cccs = []
-
-
-    def get_decoded_values(self, channel=None):
-        if channel is None:
-            dec_vals_list = self.decoded_values.items()
-            if len(dec_vals_list) == 0:
-                print("ORSIS3302Model: Error: decoded_values not built yet!")
-                return None
-            return list(dec_vals_list)[0][1] # Get first thing we find
-        if channel in self.decoded_values: return self.decoded_values[channel]
-        print("ORSIS3302Model: Error: No decoded values for channel", channel)
-        return None
-
-
-    def set_object_info(self, object_info):
-        self.object_info = object_info
-
-        # parse object_info for important info
-        for card_dict in self.object_info:
-            crate = card_dict['Crate']
-            card = card_dict['Card']
-
-            int_enabled_mask = card_dict['internalTriggerEnabledMask']
-            ext_enabled_mask = card_dict['externalTriggerEnabledMask']
-            enabled_mask = int_enabled_mask | ext_enabled_mask
-            for channel in range(10):
-                # only care about enabled channels
-                if not ((enabled_mask >> channel) & 0x1): continue
-
-                ccc = get_ccc(crate, card, channel)
-                # save list of enabled channels
-                #self.enabled_cccs.append(ccc)
-
-                self.decoded_values[ccc] = copy.deepcopy(self.decoded_values_template)
-                sd = self.decoded_values[ccc] # alias
-
-                # get trace length(s). Should all be the same until
-                # multi-buffer mode is implemented AND each channel has its
-                # own buffer
-                trace_length = card_dict['sampleLengths'][int(channel/2)]
-                if trace_length <= 0 or trace_length > 2**16:
-                    print('SIS3316ORCADecoder Error: invalid trace_length', trace_length)
-                    sys.exit()
-                self.decoded_values[ccc]['waveform']['length'] = trace_length
 
-
-    def max_n_rows_per_packet(self):
-        return 1
-
-
-    def decode_packet(self, packet, lh5_tables, packet_id, header_dict, verbose=False):
-        """
-        see README for the 32-bit data word diagram
-        """
-        # interpret the raw event data into numpy arrays of 16 and 32 bit ints
-        # does not copy data. p32 and p16 are read-only
-        p32 = np.frombuffer(packet, dtype=np.uint32)
-        p16 = np.frombuffer(packet, dtype=np.uint16)
+    def set_header(self, header):
+        self.header = header
+
+        obj_info_dict = header.get_object_info("ORSIS3302Model")
+        # Loop over crates, cards, build decoded values for enabled channels
+        for crate in obj_info_dict:
+            for card in obj_info_dict[crate]:
+                int_enabled_mask = obj_info_dict[crate][card][
+                    "internalTriggerEnabledMask"
+                ]
+                ext_enabled_mask = obj_info_dict[crate][card][
+                    "externalTriggerEnabledMask"
+                ]
+                enabled_mask = int_enabled_mask | ext_enabled_mask
+                for channel in range(8):
+                    # only care about enabled channels
+                    if not ((enabled_mask >> channel) & 0x1):
+                        continue
+
+                    ccc = get_ccc(crate, card, channel)
+
+                    self.decoded_values[ccc] = copy.deepcopy(
+                        self.decoded_values_template
+                    )
+
+                    # get trace length(s). Should all be the same until
+                    # multi-buffer mode is implemented AND each channel has its
+                    # own buffer
+                    trace_length = obj_info_dict[crate][card]["sampleLengths"][
+                        int(channel / 2)
+                    ]
+                    if trace_length <= 0 or trace_length > 2**16:
+                        raise RuntimeError(
+                            f"SIS3316ORCADecoder Error: invalid trace_length {trace_length}"
+                        )
+                        sys.exit()
+                    self.decoded_values[ccc]["waveform"]["wf_len"] = trace_length
+
+    def get_key_list(self):
+        key_list = []
+        for key in self.decoded_values.keys():
+            key_list += [key]
+        return key_list
+
+    def get_decoded_values(self, key=None):
+        if key is None:
+            dec_vals_list = self.decoded_values.values()
+            if len(dec_vals_list) >= 0:
+                return list(dec_vals_list)[0]
+            raise RuntimeError("decoded_values not built")
+        if key in self.decoded_values:
+            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"""
+        evt_rbkd = rbl.get_keyed_dict()
 
         # read the crate/card/channel first
-        crate = (p32[0] >> 21) & 0xF
-        card = (p32[0] >> 16) & 0x1F
-        channel = (p32[0] >> 8) & 0xFF
+        crate = (packet[1] >> 21) & 0xF
+        card = (packet[1] >> 16) & 0x1F
+        channel = (packet[1] >> 8) & 0xFF
         ccc = get_ccc(crate, card, channel)
 
-        # aliases for brevity
-        tb = lh5_tables
-        # if the first key is an int, then there are different tables for
-        # each channel.
-        if isinstance(list(tb.keys())[0], int):
-            if ccc not in lh5_tables:
-                if ccc not in self.skipped_channels:
-                    self.skipped_channels[ccc] = 0
-                self.skipped_channels[ccc] += 1
-                return
-            tb = lh5_tables[ccc]
-        ii = tb.loc
+        # get the table for this crate/card/channel
+        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
+        tbl = evt_rbkd[ccc].lgdo
+        ii = evt_rbkd[ccc].loc
 
         # store packet id
-        tb['packet_id'].nda[ii] = packet_id
+        tbl["packet_id"].nda[ii] = packet_id
 
         # read the rest of the record
-        n_lost_msb = (p32[0] >> 25) & 0x7F
-        n_lost_lsb = (p32[0] >> 2) & 0x7F
-        n_lost_records = (n_lost_msb << 7) + n_lost_lsb
-        tb['crate'].nda[ii] = (p32[0] >> 21) & 0xF
-        tb['card'].nda[ii] = (p32[0] >> 16) & 0x1F
-        tb['channel'].nda[ii] = (p32[0] >> 8) & 0xFF
-        buffer_wrap = p32[0] & 0x1
-        wf_length32 = p32[1]
-        ene_wf_length32 = p32[2]
-        evt_header_id = p32[3] & 0xFF
-        tb['timestamp'].nda[ii] = p32[4] + ((p32[3] & 0xFFFF0000) << 16)
-        last_word = p32[-1]
+        # 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
+        last_word = packet[-1]
 
         # get the footer
-        tb['energy'].nda[ii] = p32[-4]
-        tb['energy_first'].nda[ii] = p32[-3]
-        extra_flags = p32[-2]
+        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
+        p16 = np.frombuffer(packet, dtype=np.uint16)
 
         # compute expected and actual array dimensions
         wf_length16 = 2 * wf_length32
-        orca_helper_length16 = 2
-        sis_header_length16 = 12 if buffer_wrap else 8
-        header_length16 = orca_helper_length16 + sis_header_length16
         ene_wf_length16 = 2 * ene_wf_length32
+        orca_header_length16 = 8
+        sis_header_length16 = 8 if buffer_wrap else 4
+        header_length16 = orca_header_length16 + sis_header_length16
         footer_length16 = 8
-        expected_wf_length = len(p16) - orca_helper_length16 - sis_header_length16 - \
-            footer_length16 - ene_wf_length16
+        expected_wf_length16 = (
+            len(p16) - header_length16 - footer_length16 - ene_wf_length16
+        )
 
         # error check: waveform size must match expectations
-        if wf_length16 != expected_wf_length or last_word != 0xdeadbeef:
-            print(len(p16), orca_helper_length16, sis_header_length16,
-                  footer_length16)
-            print("ERROR: Waveform size %d doesn't match expected size %d." %
-                  (wf_length16, expected_wf_length))
-            print("       The Last Word (should be 0xdeadbeef):",
-                  hex(last_word))
-            exit()
+        if wf_length16 != expected_wf_length16 or last_word != 0xDEADBEEF:
+            raise RuntimeError(
+                f"Waveform size {wf_length16} doesn't match expected size {expected_wf_length16}. "
+                "The Last Word (should be 0xdeadbeef): {hex(last_word)}"
+            )
 
-        # indexes of stuff (all referring to the 16 bit array)
+        # 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
+        # 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 = p32[6] + header_length16 + 1
+            i_start_1 = packet[7] + header_length16 + 1
             i_stop_1 = i_wf_stop  # end of the wf record
             i_start_2 = i_wf_start  # beginning of the wf record
             i_stop_2 = i_start_1
 
         # handle the waveform(s)
-        #energy_wf = np.zeros(ene_wf_length16)  # not used rn
-        tbwf = tb['waveform']['values'].nda[ii]
+        tbwf = tbl["waveform"]["values"].nda[ii]
         if wf_length32 > 0:
             if not buffer_wrap:
-                if i_wf_stop - i_wf_start != expected_wf_length:
-                    print("ERROR: event %d, we expected %d WF samples and only got %d" %
-                          (ievt, expected_wf_length, i_wf_stope - i_wf_start))
-                tbwf[:expected_wf_length] = p16[i_wf_start:i_wf_stop]
+                if i_wf_stop - i_wf_start != expected_wf_length16:
+                    raise RuntimeError(
+                        f"We expected {expected_wf_length16} waveform samples "
+                        "and only got {i_wf_stop-i_wf_start}"
+                    )
+                tbwf[:expected_wf_length16] = p16[i_wf_start:i_wf_stop]
             else:
-                len1 = i_stop_1-i_start_1
-                len2 = i_stop_2-i_start_2
-                if len1+len2 != expected_wf_length:
-                    print("ERROR: event %d, we expected %d WF samples and only got %d" %
-                          (ievt, expected_wf_length, len1+len2))
-                    exit()
+                len1 = i_stop_1 - i_start_1
+                len2 = i_stop_2 - i_start_2
+                if len1 + len2 != expected_wf_length16:
+                    raise RuntimeError(
+                        f"We expected {expected_wf_length16} waveform samples and only got {len1+len2}"
+                    )
                 tbwf[:len1] = p16[i_start_1:i_stop_1]
-                tbwf[len1:len1+len2] = p16[i_start_2:i_stop_2]
-
-
-        # set the event number (searchable HDF5 column)
-        tb['ievt'].nda[ii] = self.ievt
-        self.ievt += 1
-        tb.push_row()
-
-
-class ORCAGretina4M(OrcaDecoder):
-    """
-    Decoder for Majorana Gretina4M digitizer data
-
-    NOTE: Tom Caldwell made some nice new summary slides on a 2019 LEGEND call
-    https://indico.legend-exp.org/event/117/contributions/683/attachments/467/717/mjd_data_format.pdf
-    """
-    def __init__(self, *args, **kwargs):
-
-        self.decoder_name = 'ORGretina4MWaveformDecoder'
-        self.orca_class_name = 'ORGretina4MModel'
-
-        self.decoded_values_template = {
-            'packet_id': {
-               'dtype': 'uint32',
-             },
-            'ievt': {
-              'dtype': 'uint32',
-            },
-            'energy': {
-              'dtype': 'uint32',
-              'units': 'adc',
-            },
-            'timestamp': {
-              'dtype': 'uint32',
-              'units': 'clock_ticks',
-            },
-            'crate': {
-              'dtype': 'uint8',
-            },
-            'card': {
-              'dtype': 'uint8',
-            },
-            'channel': {
-              'dtype': 'uint8',
-            },
-            "board_id": {
-              'dtype': 'uint32',
-            },
-            'waveform': {
-              'dtype': 'int16',
-              'datatype': 'waveform',
-              'length': 2016, # shorter if multispampling is used
-              'sample_period': 10,
-              'sample_period_units': 'ns',
-              'units': 'adc',
-            },
-        }
-
-        super().__init__(*args, **kwargs)
-        self.decoded_values = {}
-        self.ievt = 0
-        self.skipped_channels = {}
-        self.use_MS = False
-        self.wf_skip = 16 # the first ~dozen samples are sometimes junk
-        # channel pars for multisampling mode
-        self.ft_len = {}
-        self.ps = {}
-        self.div = {}
-        self.rises = np.zeros(2016)
-        self.remainders = np.zeros(2016)
-
-
-    def get_decoded_values(self, channel=None):
-        if channel is None:
-            dec_vals_list = self.decoded_values.items()
-            if len(dec_vals_list) == 0:
-                print("ORGretina4MModel: Error: decoded_values not built yet!")
-                return None
-            return list(dec_vals_list)[0][1] # Get first thing we find
-        if channel in self.decoded_values: return self.decoded_values[channel]
-        print("ORGretina4MModel: Error: No decoded values for channel", channel)
-        print(self.decoded_values.keys())
-        return None
-
-
-    def max_n_rows_per_packet(self):
-        return 1
-
-
-    def set_object_info(self, object_info):
-        self.object_info = object_info
-
-        # parse object_info for important info
-        for card_dict in self.object_info:
-            crate = card_dict['Crate']
-            card = card_dict['Card']
-
-            is_enabled = card_dict['Enabled']
-            ftcnt = card_dict['FtCnt']
-            presum_rates = [ 2, 4, 8, 10 ] # number presummed in MS
-            mrpsrt = card_dict['Mrpsrt'] # index for channel's presum rate
-            dividers = [1, 2, 4, 8 ] # dividers for presummed data
-            mrpsdv = card_dict['Mrpsdv'] # index for channel's divider
-            for channel in range(10):
-                # only care about enabled channels
-                if not is_enabled[channel]: continue
-                ccc = get_ccc(crate, card, channel)
-                self.decoded_values[ccc] = copy.deepcopy(self.decoded_values_template)
-                sd = self.decoded_values[ccc] # alias
-
-                # find MS parameters
-                # MS is on if FtCnt > 0
-                # forget pre-rising-edge MS: it's broken so MJ doesn't use it
-                # Skip samples at beginning, fully sample, then FtCnt samples of
-                # pre-sampled, divided by div. Make one long fully-sampled wf.
-                ft_len = ftcnt[channel]
-                self.ft_len[ccc] = ft_len
-                if self.is_multisampled(ccc):
-                    ps = presum_rates[mrpsrt[channel]]
-                    self.ps[ccc] = ps
-                    self.div[ccc] = dividers[mrpsdv[channel]]
-                    # chop off 3 values at the end because 2 are bad and we need
-                    # one for interpolation
-                    min_len = 2018 - ft_len - self.wf_skip + (ps-1)*(ft_len-3)
-                    sd['waveform']['length'] = min_len
-                    if min_len > len(self.remainders):
-                        self.remainders.resize(min_len)
-                else: sd['waveform']['length'] = 2016 - self.wf_skip
-
-
-    def is_multisampled(self, ccc):
-        if ccc in self.ft_len: return self.ft_len[ccc] > 0
-        else: print('channel', ccc, 'not in ft_len...')
-        return False
-
-
-    def decode_packet(self, packet, lh5_tables, packet_id, header_dict, verbose=False):
-        """
-        Parse the header for an individual event
-        """
-        pu16 = np.frombuffer(packet, dtype=np.uint16)
-        p16 = np.frombuffer(packet, dtype=np.int16)
-
-        crate = (pu16[1] >> 5) & 0xF
-        card = pu16[1] & 0x1F
-        channel = pu16[4] & 0xf
-        ccc = get_ccc(crate, card, channel)
-
-        # aliases for brevity
-        tb = lh5_tables
-        if not isinstance(tb, Table):
-            if ccc not in lh5_tables:
-                if ccc not in self.skipped_channels:
-                    self.skipped_channels[ccc] = 0
-                self.skipped_channels[ccc] += 1
-                return
-            tb = lh5_tables[ccc]
-        ii = tb.loc
-
-        tb['packet_id'].nda[ii] = packet_id
-        tb['ievt'].nda[ii] = self.ievt
-        tb['energy'].nda[ii] = pu16[9] + ((pu16[10] & 0x1FF) << 16)
-        tb['timestamp'].nda[ii] = pu16[6] + (pu16[7] << 16) + (pu16[8] << 32)
-        tb['crate'].nda[ii] = crate
-        tb['card'].nda[ii] = card
-        tb['channel'].nda[ii] = channel
-        tb['board_id'].nda[ii] = (pu16[4] & 0xFFF0) >> 4
-
-        # wf starts from p16[18] and is always 2018 samples
-        # we will chop off the start (~a dozen) and end (last 2), but not until
-        # after we have handled multisampling
-        wf = p16[18:]
-        wf_len = 2018
-        if self.is_multisampled(ccc):
-            # get ccc pars
-            ps = self.ps[ccc]
-            div = self.div[ccc]
-            ratio = div / ps
-
-            # find start of presummed section
-            # because it's not always self.ft_len :(
-            ift = wf_len - self.ft_len[ccc] - 2
-            min_diff = np.inf
-            min_diff_ift = ift
-            for i in range(self.wf_skip+1):
-                d1 = abs(wf[ift+i]*ratio - wf[ift+i-1])
-                if d1 < min_diff:
-                    min_diff = d1
-                    min_diff_ift = ift+i
-            ift = min_diff_ift
-            ift_out = ift - self.wf_skip
-
-            # copy over fully-sampled portion
-            tb['waveform']['values'].nda[ii][:ift_out] = wf[self.wf_skip:ift]
-
-            # compute slopes for interpolation
-            # rise[i-1] is the slope prior to sample i, while rise[i] is the
-            # slope following it
-            if len(self.rises) != len(wf): self.rises.resize(len(wf)-1)
-            self.rises[:] = wf[1:] - wf[:-1]
-            # correct the value right before ift
-            self.rises[ift-1] = wf[ift] - np.sum(wf[ift-ps:ift]) / div
-
-            # store double-precision values in self.remainders (later it will be
-            # used to compute remainders and do round-off
-            # Note: the interpolation done here is meant to preserve the
-            # presumming: re-presumming should reproduce the original waveform
-            rem_len = len(self.remainders) - ift_out
-            for ips in range(ps):
-                nn = int(np.floor( (rem_len - ips - 1) / ps ) + 1)
-                self.remainders[ift_out+ips::ps] = wf[ift:ift+nn]
-                self.remainders[ift_out+ips::ps] -= (2*ips+1)/(2*ps)*self.rises[ift-1:ift+nn-1]
-                self.remainders[ift_out+ips::ps] *= ratio
-
-            # now set the output based on rounding cumulative remainders
-            wf_out = tb['waveform']['values'].nda[ii]
-            if len(self.remainders) != len(wf_out):
-                print("Error: remainders len", len(self.remainders), "but wf_out len", len(wf_out))
-                return
-            for ips in range(ps):
-                np.rint(self.remainders[ift_out+ips::ps], out=wf_out[ift_out+ips::ps], casting='unsafe')
-                if ips == ps-1: break;
-                self.remainders[ift_out+ips::ps] -= wf_out[ift_out+ips::ps]
-                self.remainders[ift_out+ips+1::ps] += self.remainders[ift_out+ips:-1:ps]
-
-        else:
-            length = 2016 - self.wf_skip
-            start = 16+self.wf_skip
-            tb['waveform']['values'].nda[ii][:] = p16[start:start+length]
-
-        tb.push_row()
-
-        # update the event number (searchable HDF5 column)
-        self.ievt += 1
-
-
-class SIS3316ORCADecoder(OrcaDecoder):
-    """
-    Decode ORCA Struck 3316 digitizer data.
-    Thanks to J. Browning of COHERENT for getting this updated!
-
-    TODO:
-    handle per-channel data (gain, ...)
-    most metadata of Struck header (energy, ...)
-    """
-    def __init__(self, *args, **kwargs):
-
-        self.decoder_name = 'ORSIS3316WaveformDecoder'
-        self.orca_class_name = 'ORSIS3316Model'
-
-        # store an entry for every event
-        self.decoded_values_template = {
-            'packet_id': {
-               'dtype': 'uint32',
-             },
-            'ievt': {
-              'dtype': 'uint32',
-            },
-            'energy': {
-              'dtype': 'uint32',
-              'units': 'adc',
-            },
-            'energy_first': {
-              'dtype': 'uint32',
-            },
-            'timestamp': {
-              'dtype': 'uint64',
-              'units': 'clock_ticks',
-            },
-            'crate': {
-              'dtype': 'uint8',
-            },
-            'card': {
-              'dtype': 'uint8',
-            },
-            'channel': {
-              'dtype': 'uint8',
-            },
-            'waveform': {
-              'dtype': 'uint16',
-              'datatype': 'waveform',
-              'length': 65532, # max value. override this before initializing buffers to save RAM
-              'sample_period': 8, # override if a different clock rate is used
-              'sample_period_units': 'ns',
-              'units': 'adc',
-            },
-        }
-        super().__init__(*args, **kwargs) # also initializes the garbage df (whatever that means...)
-
-        # self.event_header_length = 1 #?
-        self.decoded_values = {}
-        self.skipped_channels = {}
-        self.ievt = 0
-
-
-    def get_decoded_values(self, channel=None):
-        if channel is None:
-            dec_vals_list = self.decoded_values.items()
-            if len(dec_vals_list) == 0:
-                print("ORSIS3302Model: Error: decoded_values not built yet!")
-                return None
-            return list(dec_vals_list)[0][1] # Get first thing we find
-        if channel in self.decoded_values:
-            return self.decoded_values[channel]
-        print("ORSIS3316Model: Error: No decoded values for channel", channel)
-        return None
-
-
-    def set_object_info(self, object_info):
-        self.object_info = object_info
-        # parse object_info for important info
-        for card_dict in self.object_info:
-            crate = card_dict['Crate']
-            card = card_dict['Card']
-            trace_length = card_dict['rawDataBufferLen']
-            import copy
-
-            for channel in range(16):
-                # get ccc and make an empty table for that ccc
-                # TODO: should try and only do this for enabled channels
-                ccc = get_ccc(crate, card, channel)
-                self.decoded_values[ccc] = copy.deepcopy(self.decoded_values_template)
-
-                if trace_length <= 0 or trace_length > 2**16:
-                    print('SIS3316ORCADecoder Error: invalid trace_length', trace_length)
-                    sys.exit()
-
-                self.decoded_values[ccc]['waveform']['length'] = trace_length
-
-
-
-    def max_n_rows_per_packet(self):
-        return 1
-
-
-    def decode_packet(self, packet, lh5_tables, packet_id, header_dict, verbose=False):
-
-        # parse the raw event data into numpy arrays of 16 and 32 bit ints
-        evt_data_32 = np.fromstring(packet, dtype=np.uint32)
-        evt_data_16 = np.fromstring(packet, dtype=np.uint16)
-
-        # tb = lh5_tables
-
-
-        # read the crate/card/channel first
-        crate = (evt_data_32[0] >> 21) & 0xF
-        card = (evt_data_32[0] >> 16) & 0x1F
-        channel = (evt_data_32[0] >> 8) & 0xFF
-        ccc = get_ccc(crate, card, channel)
-
-
-        if isinstance(list(lh5_tables.keys())[0], int):
-            if ccc not in lh5_tables:
-                print('ccc not in lh5_tables')
-                if ccc not in self.skipped_channels:
-                    self.skipped_channels[ccc] = 0
-                self.skipped_channels[ccc] += 1
-                return
-            tb = lh5_tables[ccc]
-        ii = tb.loc
-
-        tb['packet_id'].nda[ii] = packet_id
-
-        # TODO: Figure out the header, particularly card/crate/channel/timestamp
-        n_lost_msb = 0
-        n_lost_lsb = 0
-        n_lost_records = 0
-        tb['crate'].nda[ii] = evt_data_32[3]
-        tb['card'].nda[ii] = evt_data_32[4]
-        try:
-            tb['channel'].nda[ii] = (evt_data_32[9] & 0xFFF0) >> 4
-        except:
-            print('Something went wrong')
-            tb['channel'].nda[ii] = 33
-        buffer_wrap = 0
-        crate_card_chan = crate + card + channel
-        wf_length_32 = 0
-        ene_wf_length = evt_data_32[4]
-        evt_header_id = 0
-        try:
-            tb['timestamp'].nda[ii] = evt_data_32[10] + ((evt_data_32[9] & 0xffff0000) << 16)
-        except:
-            print('something went wrong here too')
-            tb['timestamp'].nda[ii] = 0
-
-        # compute expected and actual array dimensions
-        # wf_length16 = 65000 # OK this stuff is just random <--
-        orca_helper_length16 = 52
-        header_length16 = orca_helper_length16
-        ene_wf_length16 = 2 * ene_wf_length
-        footer_length16 = 0
-
-        #expected_wf_length = (len(evt_data_16) - header_length16 - ene_wf_length16)
-        expected_wf_length = (len(evt_data_16) - header_length16) #- ene_wf_length16)
-
-        # print('expected_wf_length... ', len(evt_data_16) - header_length16)
-
-        # if wf_length16 != expected_wf_length:
-        #     print(len(evt_data_16), orca_helper_length16, header_length16,
-        #          footer_length16)
-        #     print("ERROR: Waveform size %d doesn't match expected size %d." %
-        #       (wf_length16, expected_wf_length))
-        #     print("       The Last Word (should be 0xdeadbeef):",)
-        #     exit()
-
-        # indexes of stuff (all referring to the 16 bit array)
-        i_wf_start = header_length16
-        #i_wf_stop = i_wf_start + wf_length16
-        i_wf_stop = i_wf_start + expected_wf_length
-        i_ene_start = i_wf_stop + 1
-        i_ene_stop = i_ene_start + ene_wf_length16
-
-        tbwf = tb['waveform']['values'].nda[ii]
-        # handle the waveform(s)
-        # if wf_length16 > 0:
-        if expected_wf_length > 0:
-            # print('len(tb[waveform][values].nda[ii]) ', len(tb['waveform']['values'].nda[ii]))
-            # print(len(evt_data_16[i_wf_start:i_wf_stop]))
-            try:
-                tb['waveform']['values'].nda[ii] = evt_data_16[i_wf_start:i_wf_stop]
-            except:
-                print('hmm.. packet id: ', packet_id)
-                print('  got: ',len(evt_data_16[i_wf_start:i_wf_stop]))
-                #print(evt_data_32[i_wf_start:i_wf_stop])
-                print('  expected: ',len(tb['waveform']['values'].nda[ii]))
-
-        # TODO check if number of events matches expected
-        # if len(wf_data) != expected_wf_length:
-        #    print("ERROR: We expected %d WF samples and only got %d" %
-        #          (expected_wf_length, len(wf_data)))
-        #    exit()
-
-        # final raw wf array
+                tbwf[len1 : len1 + len2] = p16[i_start_2:i_stop_2]
 
-        # set the event number (searchable HDF5 column)\
-        tb['ievt'].nda[ii] = self.ievt
-        self.ievt += 1
-        tb.push_row()
+        evt_rbkd[ccc].loc += 1
+        return evt_rbkd[ccc].is_full()
diff --git a/src/pygama/raw/orca/orca_flashcam.py b/src/pygama/raw/orca/orca_flashcam.py
index 01a461a49..d98729366 100644
--- a/src/pygama/raw/orca/orca_flashcam.py
+++ b/src/pygama/raw/orca/orca_flashcam.py
@@ -1,102 +1,113 @@
 import copy
 import gc
 import logging
+from typing import Any
 
 import numpy as np
 
-from ..fc.fc_event_decoder import fc_decoded_values
-from .orca_base import OrcaDecoder
+from pygama.raw.fc.fc_event_decoder import fc_decoded_values
+from pygama.raw.orca.orca_base import OrcaDecoder
+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__)
 
 
-def get_key(fcid, ch): return (fcid-1)*1000 + ch
+def get_key(fcid, ch: int) -> int:
+    return (fcid - 1) * 1000 + ch
 
-def get_fcid(key): return int(np.floor(key/1000))+1
 
-def get_ch(key): return key % 1000
+def get_fcid(key: int) -> int:
+    return int(np.floor(key / 1000)) + 1
 
 
+def get_ch(key: int) -> int:
+    return key % 1000
+
 
 class ORFlashCamListenerConfigDecoder(OrcaDecoder):
-    '''
-    Decoder for FlashCam listener config written by ORCA
-    '''
-    def __init__(self, header=None, **kwargs):
-        """
-        DOCME
-        """
+    """Decoder for FlashCam listener config written by ORCA."""
+
+    def __init__(self, header: OrcaHeader = None, **kwargs) -> None:
         # up through ch_inputnum, these are in order of the fcio data format
         # for similicity.  append any additional values after this.
         self.decoded_values = {
-            'readout_id':   { 'dtype': 'uint16', },
-            'fcid':         { 'dtype': 'uint16', },
-            'telid':        { 'dtype': 'int32',  },
-            'nadcs':        { 'dtype': 'int32',  },
-            'ntriggers':    { 'dtype': 'int32',  },
-            'nsamples':     { 'dtype': 'int32',  },
-            'adcbits':      { 'dtype': 'int32',  },
-            'sumlength':    { 'dtype': 'int32',  },
-            'blprecision':  { 'dtype': 'int32',  },
-            'mastercards':  { 'dtype': 'int32',  },
-            'triggercards': { 'dtype': 'int32',  },
-            'adccards':     { 'dtype': 'int32',  },
-            'gps':          { 'dtype': 'int32',  },
-            'ch_boardid':   { 'dtype': 'uint16',
-                              'datatype':
-                              'array_of_equalsized_arrays<1,1>{real}',
-                              'length': 2400, },
-            'ch_inputnum':  { 'dtype': 'uint16',
-                              'datatype':
-                              'array_of_equalsized_arrays<1,1>{real}',
-                              'length': 2400, },
-            }
+            "readout_id": {"dtype": "uint16"},
+            "fcid": {"dtype": "uint16"},
+            "telid": {"dtype": "int32"},
+            "nadcs": {"dtype": "int32"},
+            "ntriggers": {"dtype": "int32"},
+            "nsamples": {"dtype": "int32"},
+            "adcbits": {"dtype": "int32"},
+            "sumlength": {"dtype": "int32"},
+            "blprecision": {"dtype": "int32"},
+            "mastercards": {"dtype": "int32"},
+            "triggercards": {"dtype": "int32"},
+            "adccards": {"dtype": "int32"},
+            "gps": {"dtype": "int32"},
+            "ch_boardid": {
+                "dtype": "uint16",
+                "datatype": "array_of_equalsized_arrays<1,1>{real}",
+                "length": 2400,
+            },
+            "ch_inputnum": {
+                "dtype": "uint16",
+                "datatype": "array_of_equalsized_arrays<1,1>{real}",
+                "length": 2400,
+            },
+        }
         super().__init__(header=header, **kwargs)
 
-
-    def get_decoded_values(self, key=None):
+    def get_decoded_values(self, key: int = None) -> dict[str, Any]:
         return self.decoded_values
 
-
-    def decode_packet(self, packet, packet_id, rbl):
+    def decode_packet(
+        self, packet: OrcaPacket, packet_id: int, rbl: RawBufferLibrary
+    ) -> bool:
         if len(rbl) != 1:
-            print(f"FC config decoder: got {len(rbl)} rb's, should have only 1 (no keyed decoded values)")
+            log.warning(
+                f"got {len(rbl)} rb's, should have only 1 (no keyed decoded values)"
+            )
         rb = rbl[0]
         tbl = rb.lgdo
-        ii  = rb.loc
+        ii = rb.loc
 
         int_packet = packet.astype(np.int32)
-        tbl['readout_id'].nda[ii]  = (int_packet[1] & 0xffff0000) >> 16
-        tbl['fcid'].nda[ii] =  int_packet[1] & 0x0000ffff
+        tbl["readout_id"].nda[ii] = (int_packet[1] & 0xFFFF0000) >> 16
+        tbl["fcid"].nda[ii] = int_packet[1] & 0x0000FFFF
 
-        for i,k in enumerate(self.decoded_values):
-            if i < 2: continue
+        for i, k in enumerate(self.decoded_values):
+            if i < 2:
+                continue
             tbl[k].nda[ii] = int_packet[i]
-            if k == 'gps': break
+            if k == "gps":
+                break
 
-        packet = packet[list(self.decoded_values.keys()).index('ch_boardid'):]
+        packet = packet[list(self.decoded_values.keys()).index("ch_boardid") :]
         for i in range(len(packet)):
-            tbl['ch_boardid'].nda[ii][i]  = (packet[i] & 0xffff0000) >> 16
-            tbl['ch_inputnum'].nda[ii][i] =  packet[i] & 0x0000ffff
+            tbl["ch_boardid"].nda[ii][i] = (packet[i] & 0xFFFF0000) >> 16
+            tbl["ch_inputnum"].nda[ii][i] = packet[i] & 0x0000FFFF
 
         # check that the ADC decoder has the right number of samples
         objs = []
         for obj in gc.get_objects():
             try:
-                if isinstance(obj, ORFlashCamADCWaveformDecoder): objs.append(obj)
+                if isinstance(obj, ORFlashCamADCWaveformDecoder):
+                    objs.append(obj)
             except ReferenceError:
                 # avoids "weakly-referenced object no longer exists"
                 pass
         if len(objs) != 1:
-            log.warning(f'Got {len(objs)} ORFlashCamADCWaveformDecoders in memory!')
-        else: objs[0].assert_nsamples(tbl['nsamples'].nda[ii], tbl['fcid'].nda[ii])
+            log.warning(f"got {len(objs)} ORFlashCamADCWaveformDecoders in memory!")
+        else:
+            objs[0].assert_nsamples(tbl["nsamples"].nda[ii], tbl["fcid"].nda[ii])
 
         rb.loc += 1
         return rb.is_full()
 
 
-
-class ORCAFlashCamListenerStatusDecoder(OrcaDecoder):
+class ORFlashCamListenerStatusDecoder(OrcaDecoder):
     """
     Decoder for FlashCam status packets written by ORCA
 
@@ -106,305 +117,110 @@ class ORCAFlashCamListenerStatusDecoder(OrcaDecoder):
     flattened before populating the lh5 table.
     """
 
-    # def __init__(self, *args, **kwargs):
-
-    #     self.decoder_name    = 'ORFlashCamListenerStatusDecoder'
-    #     self.orca_class_name = 'ORFlashCamListenerModel'
-    #     self.nOtherErrors    = np.uint32(5)
-    #     self.nEnvMonitors    = np.uint32(16)
-    #     self.nCardTemps      = np.uint32(5)
-    #     self.nCardVoltages   = np.uint32(6)
-    #     self.nADCTemps       = np.uint32(2)
-    #     self.nCTILinks       = np.uint32(4)
-    #     self.nCards          = np.uint32(1)
-
-    #     self.decoded_values = {
-    #         'readout_id':  { 'dtype': 'uint16', },
-    #         'listener_id': { 'dtype': 'uint16', },
-    #         'cards':       { 'dtype': 'int32',  },
-    #         'status':      { 'dtype': 'int32',  },
-    #         'statustime':  { 'dtype': 'float64', 'units': 's', },
-    #         'cputime':     { 'dtype': 'float64', 'units': 's', },
-    #         'startoffset': { 'dtype': 'float64', 'units': 's', },
-    #         'card_fcio_id':  {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_status': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_event_number': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_pps_count': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_tick_count': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_max_ticks': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_total_errors': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_env_errors': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_cti_errors': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_link_errors': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards, },
-    #         'card_other_errors': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards * self.nOtherErrors, },
-    #         'card_temp': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards * self.nCardTemps,
-    #             'units':        'mC', },
-    #         'card_voltage': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards * self.nCardVoltages,
-    #             'units':        'mV', },
-    #         'card_current': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards,
-    #             'units':        'mA', },
-    #         'card_humidity': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards,
-    #             'units':        'o/oo', },
-    #         'card_adc_temp': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards * self.nADCTemps,
-    #             'units':        'mC', },
-    #         'card_cti_link': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards * self.nCTILinks, },
-    #         'card_card_link_state': {
-    #             'dtype':        'uint32',
-    #             'datatype':     'array<1>{array<1>{real}}',
-    #             'length_guess':  self.nCards * self.nCards, },
-    #     }
-
-    #     # arrays to temporarily store card-level decoded data
-    #     self.cdata = {}
-    #     self.resize_card_data(ncards=self.nCards)
-
-    #     super().__init__(args, kwargs)
-
-
-    # def resize_card_data(self, ncards):
-    #     try: ncards = np.uint32(ncards)
-    #     except ValueError: return
-    #     if ncards == 0: return
-    #     for key in self.decoded_values:
-    #         # ignore keys that aren't card level
-    #         if key.find('card_') != 0: continue
-    #         try:
-    #             # skip keys for things that aren't arrays with a length_guess
-    #             if self.decoded_values[key]['datatype'].find('array') != 0:
-    #                 continue
-    #             length = self.decoded_values[key]['length_guess']
-    #             try:
-    #                 # resize if ncards differs from the old shape
-    #                 oldshape = self.cdata[key].shape
-    #                 if oldshape[0] == ncards: continue
-    #                 if key.find('card_card_') == 0:
-    #                     self.cdata[key].resize((ncards,ncards,) + oldshape[2:])
-    #                 else:
-    #                     self.cdata[key].resize((ncards,) + oldshape[1:])
-    #             except KeyError:
-    #                 # if the key didn't exist set the ndarray for this key
-    #                 if ((length == ncards or (length % ncards) != 0) and
-    #                     key.find('card_card_') == -1):
-    #                     self.cdata[key] = np.ndarray(shape=(length),
-    #                                                  dtype=np.uint32)
-    #                 else:
-    #                     nval = np.uint32(length / ncards)
-    #                     self.cdata[key] = np.ndarray(shape=(ncards, nval),
-    #                                                  dtype=np.uint32)
-    #         except KeyError: continue
-    #     # set nCards to allow for not calling this function during decoding
-    #     self.nCards = ncards
-
-
-    # def get_decoded_values(self, key=None):
-    #     return self.decoded_values
-
-
-    # def max_n_rows_per_packet(self):
-    #     return 1
-
-
-    # def decode_packet(self, packet, lh5_tables, packet_id, header_dict):
-
-    #     data = np.frombuffer(packet, dtype=np.uint32)
-    #     tbl  = lh5_tables
-    #     ii   = tbl.loc
-
-    #     # populate the packet header information
-    #     tbl['readout_id'].nda[ii]  = (data[0] & 0xffff0000) >> 16
-    #     tbl['listener_id'].nda[ii] =  data[0] & 0x0000ffff
-    #     tbl['status'].nda[ii]      = np.int32(data[1])
-    #     tbl['statustime'].nda[ii]  = np.float64(data[2] + data[3] / 1.0e6)
-    #     tbl['cputime'].nda[ii]     = np.float64(data[4] + data[5] / 1.0e6)
-    #     tbl['startoffset'].nda[ii] = np.float64(data[7] + data[8] / 1.0e6)
-    #     tbl['cards'].nda[ii]       = np.int32(data[12])
-
-    #     # resize the card level data if necessary
-    #     if data[12] != self.nCards:
-    #         print('ORlashCamListenerStatusDecoder: resizing card arrays '
-    #               'from', self.nCards, ' cards to', data[12])
-    #         self.resize_card_data(ncards=data[12])
-
-    #     # set the local card level data
-    #     for i in range(np.int(data[12])):
-    #         j = 14 + i * (data[12] + 14 + self.nOtherErrors +
-    #                       self.nEnvMonitors + self.nCTILinks)
-
-    #         self.cdata['card_fcio_id'][i]      = data[j]
-    #         self.cdata['card_status'][i]       = data[j+1]
-    #         self.cdata['card_event_number'][i] = data[j+2]
-    #         self.cdata['card_pps_count'][i]    = data[j+3]
-    #         self.cdata['card_tick_count'][i]   = data[j+4]
-    #         self.cdata['card_max_ticks'][i]    = data[j+5]
-    #         self.cdata['card_total_errors'][i] = data[j+10]
-    #         self.cdata['card_env_errors'][i]   = data[j+11]
-    #         self.cdata['card_cti_errors'][i]   = data[j+12]
-    #         self.cdata['card_link_errors'][i]  = data[j+13]
-    #         k = j + 14
-    #         self.cdata['card_other_errors'][i][:]= data[k:k+self.nOtherErrors]
-    #         k += self.nOtherErrors
-    #         self.cdata['card_temp'][i][:]        = data[k:k+self.nCardTemps]
-    #         k += self.nCardTemps
-    #         self.cdata['card_voltage'][i][:]     = data[k:k+self.nCardVoltages]
-    #         k += self.nCardVoltages
-    #         self.cdata['card_current'][i]        = data[k]
-    #         self.cdata['card_humidity'][i]       = data[k+1]
-    #         k += 2
-    #         self.cdata['card_adc_temp'][i][:]    = data[k:k+self.nADCTemps]
-    #         k += self.nADCTemps
-    #         self.cdata['card_cti_link'][i][:]    = data[k:k+self.nCTILinks]
-    #         k += self.nCTILinks
-    #         self.cdata['card_card_link_state'][i][:]  = data[k:k+data[12]]
-
-    #     # populate the card level data with the flattened local data, then push
-    #     for key in self.cdata:
-    #         tbl[key].set_vector(ii, self.cdata[key].flatten())
-
-    #     tbl.push_row()
+    def __init__(self, header: OrcaHeader = None, **kwargs) -> None:
+        raise NotImplementedError(
+            "decoding of FlashCam status packets (written by ORCA) is not implemented yet"
+        )
+
 
 class ORFlashCamADCWaveformDecoder(OrcaDecoder):
-    """
-    Decoder for FlashCam ADC data written by ORCA
-    """
-    def __init__(self, header=None, **kwargs):
-        """
-        DOCME
-        """
+    """Decoder for FlashCam ADC data written by ORCA."""
+
+    def __init__(self, header: OrcaHeader = None, **kwargs) -> None:
         # start with the values defined in fcdaq
         self.decoded_values_template = copy.deepcopy(fc_decoded_values)
         # add header values from Orca
-        self.decoded_values_template.update( {
-            'crate' :   { 'dtype': 'uint8',  },
-            'card' :    { 'dtype': 'uint8',  },
-            'ch_orca' : { 'dtype': 'uint8',  },
-            'fcid' :    { 'dtype': 'uint8',  },
-        } )
-        self.decoded_values = {} # dict[fcid]
-        self.fcid = {} # dict[crate][card]
-        self.nadc = {} # dict[fcid]
+        self.decoded_values_template.update(
+            {
+                "crate": {"dtype": "uint8"},
+                "card": {"dtype": "uint8"},
+                "ch_orca": {"dtype": "uint8"},
+                "fcid": {"dtype": "uint8"},
+            }
+        )
+        self.decoded_values = {}  # dict[fcid]
+        self.fcid = {}  # dict[crate][card]
+        self.nadc = {}  # dict[fcid]
         super().__init__(header=header, **kwargs)
         self.skipped_channels = {}
 
-
-    def set_header(self, header):
+    def set_header(self, header: OrcaHeader) -> None:
         self.header = header
 
         # set up decoded values, key list, fcid map, etc. based on header info
-        fc_listener_info = header.get_readout_info('ORFlashCamListenerModel')
+        fc_listener_info = header.get_readout_info("ORFlashCamListenerModel")
         for info in fc_listener_info:
-            fcid = info['uniqueID']
+            fcid = info["uniqueID"]
             # we are going to subtract 1 from fcid for the keys, so it better start from 1
-            if fcid == 0: raise ValueError("got fcid=0 unexpectedly!")
+            if fcid == 0:
+                raise ValueError("got fcid=0 unexpectedly!")
 
-            obj_info_dict = header.get_object_info('ORFlashCamADCModel')
+            obj_info_dict = header.get_object_info("ORFlashCamADCModel")
             self.nadc[fcid] = 0
-            for child in info['children']:
+            for child in info["children"]:
                 # load self.fcid
-                crate = child['crate']
-                if crate not in self.fcid: self.fcid[crate] = {}
-                card = child['station']
+                crate = child["crate"]
+                if crate not in self.fcid:
+                    self.fcid[crate] = {}
+                card = child["station"]
                 self.fcid[crate][card] = fcid
 
                 # load self.nadc
-                self.nadc[fcid] += np.count_nonzero(obj_info_dict[crate][card]['Enabled'])
+                self.nadc[fcid] += np.count_nonzero(
+                    obj_info_dict[crate][card]["Enabled"]
+                )
 
             # we are going to shift by 1000 for each fcid, so we better not have that many adcs!
-            if self.nadc[fcid] > 1000: raise ValueError(f"got too many adc's! ({nadc})")
+            if self.nadc[fcid] > 1000:
+                raise ValueError(f"got too many adc's! ({self.nadc[fcid]})")
 
             # get the wf len for this fcid and set up decoded_values
-            wf_len = header.get_auxhw_info('ORFlashCamListenerModel', fcid)['eventSamples']
+            wf_len = header.get_auxhw_info("ORFlashCamListenerModel", fcid)[
+                "eventSamples"
+            ]
             self.decoded_values[fcid] = copy.deepcopy(self.decoded_values_template)
-            self.decoded_values[fcid]['waveform']['wf_len'] = wf_len
-
+            self.decoded_values[fcid]["waveform"]["wf_len"] = wf_len
 
-    def get_key_list(self):
+    def get_key_list(self) -> list[int]:
         key_list = []
         for fcid, nadc in self.nadc.items():
-            key_list += list( get_key(fcid, np.array(range(nadc))) )
+            key_list += list(get_key(fcid, np.array(range(nadc))))
         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: return dec_vals_list[0]
-            raise RuntimeError('decoded_values not built')
+            if len(dec_vals_list) >= 0:
+                return dec_vals_list[0]
+            raise RuntimeError("decoded_values not built")
         fcid = get_fcid(key)
-        if fcid in self.decoded_values: return self.decoded_values[fcid]
-        raise KeyError(f'no decoded values for key {key} (fcid {fcid})')
-
+        if fcid in self.decoded_values:
+            return self.decoded_values[fcid]
+        raise KeyError(f"no decoded values for key {key} (fcid {fcid})")
 
-    def assert_nsamples(self, nsamples, fcid):
-        orca_nsamples = self.decoded_values[fcid]['waveform']['wf_len']
+    def assert_nsamples(self, nsamples: int, fcid: int) -> None:
+        orca_nsamples = self.decoded_values[fcid]["waveform"]["wf_len"]
         if orca_nsamples != nsamples:
-            log.warning(f"orca miscalculated nsamples = {orca_nsamples} for fcid {fcid}, updating to {nsamples}")
-            self.decoded_values[fcid]['waveform']['wf_len'] = nsamples
-
-
-    def decode_packet(self, packet, packet_id, rbl):
-        ''' decode the orca FC ADC packet '''
+            log.warning(
+                f"orca miscalculated nsamples = {orca_nsamples} for fcid {fcid}, updating to {nsamples}"
+            )
+            self.decoded_values[fcid]["waveform"]["wf_len"] = nsamples
+
+    def decode_packet(
+        self, packet: OrcaPacket, packet_id: int, rbl: RawBufferLibrary
+    ) -> bool:
+        """Decode the ORCA FlashCam ADC packet."""
         evt_rbkd = rbl.get_keyed_dict()
 
         # unpack lengths and ids from the header words
-        orca_header_length = (packet[1] & 0xf0000000) >> 28
-        fcio_header_length = (packet[1] & 0x0fc00000) >> 22
-        wf_samples         = (packet[1] & 0x003fffc0) >> 6
-        crate              = (packet[2] & 0xf8000000) >> 27
-        card               = (packet[2] & 0x07c00000) >> 22
+        orca_header_length = (packet[1] & 0xF0000000) >> 28
+        fcio_header_length = (packet[1] & 0x0FC00000) >> 22
+        wf_samples = (packet[1] & 0x003FFFC0) >> 6
+        crate = (packet[2] & 0xF8000000) >> 27
+        card = (packet[2] & 0x07C00000) >> 22
         fcid = self.fcid[crate][card]
-        ch_orca            = (packet[2] & 0x00003c00) >> 10
-        channel            =  packet[2] & 0x000003ff
+        ch_orca = (packet[2] & 0x00003C00) >> 10
+        channel = packet[2] & 0x000003FF
         key = get_key(fcid, channel)
 
         # get the table for this crate/card/channel
@@ -417,73 +233,81 @@ def decode_packet(self, packet, packet_id, rbl):
         ii = evt_rbkd[key].loc
 
         # check that the waveform length is as expected
-        rb_wf_len = tbl['waveform']['values'].nda.shape[1]
+        rb_wf_len = tbl["waveform"]["values"].nda.shape[1]
         if wf_samples != rb_wf_len:
-            if not hasattr(self, 'wf_len_errs'): self.wf_len_errs = {}
+            if not hasattr(self, "wf_len_errs"):
+                self.wf_len_errs = {}
             # if dec_vals has been updated, orca miscalc'd and a warning has
             # already been emitted.  Otherwise, emit a new warning.
-            if wf_samples != self.decoded_values[fcid]['waveform']['wf_len']:
+            if wf_samples != self.decoded_values[fcid]["waveform"]["wf_len"]:
                 if fcid not in self.wf_len_errs:
-                    log.warning(f'got waveform from fcid {fcid} of length {wf_samples} with expected length {rb_wf_len}')
+                    log.warning(
+                        f"got waveform from fcid {fcid} of length {wf_samples} with expected length {rb_wf_len}"
+                    )
                     self.wf_len_errs[fcid] = True
             # Now resize buffer only if it is still empty.
             # Otherwise emit a warning and keep the smaller length
             if ii != 0:
                 if fcid not in self.wf_len_errs:
-                    log.warning(f'tried to resize buffer according to config record but it was not empty!')
+                    log.warning(
+                        "tried to resize buffer according to config record but it was not empty!"
+                    )
                     self.wf_len_errs[fcid] = True
-                if wf_samples > rb_wf_len: wf_samples = rb_wf_len
-            else: tbl['waveform'].resize_wf_len(wf_samples)
+                if wf_samples > rb_wf_len:
+                    wf_samples = rb_wf_len
+            else:
+                tbl["waveform"].resize_wf_len(wf_samples)
 
         # set the values decoded from the header words
-        tbl['packet_id'].nda[ii] = packet_id
-        tbl['crate'].nda[ii]     = crate
-        tbl['card'].nda[ii]      = card
-        tbl['ch_orca'].nda[ii]   = ch_orca
-        tbl['channel'].nda[ii]   = channel
-        tbl['fcid'].nda[ii]      = fcid
-        tbl['numtraces'].nda[ii] = 1
+        tbl["packet_id"].nda[ii] = packet_id
+        tbl["crate"].nda[ii] = crate
+        tbl["card"].nda[ii] = card
+        tbl["ch_orca"].nda[ii] = ch_orca
+        tbl["channel"].nda[ii] = channel
+        tbl["fcid"].nda[ii] = fcid
+        tbl["numtraces"].nda[ii] = 1
 
         # set the time offsets
         offset = orca_header_length
-        tbl['to_mu_sec'].nda[ii]      = np.int32(packet[offset])
-        tbl['to_mu_usec'].nda[ii]     = np.int32(packet[offset+1])
-        tbl['to_master_sec'].nda[ii]  = np.int32(packet[offset+2])
-        tbl['to_dt_mu_usec'].nda[ii]  = np.int32(packet[offset+3])
-        tbl['to_abs_mu_usec'].nda[ii] = np.int32(packet[offset+4])
-        tbl['to_start_sec'].nda[ii]   = np.int32(packet[offset+5])
-        tbl['to_start_usec'].nda[ii]  = np.int32(packet[offset+6])
-        toff = np.float64(packet[offset+2]) + np.float64(packet[offset+3])*1e-6
+        tbl["to_mu_sec"].nda[ii] = np.int32(packet[offset])
+        tbl["to_mu_usec"].nda[ii] = np.int32(packet[offset + 1])
+        tbl["to_master_sec"].nda[ii] = np.int32(packet[offset + 2])
+        tbl["to_dt_mu_usec"].nda[ii] = np.int32(packet[offset + 3])
+        tbl["to_abs_mu_usec"].nda[ii] = np.int32(packet[offset + 4])
+        tbl["to_start_sec"].nda[ii] = np.int32(packet[offset + 5])
+        tbl["to_start_usec"].nda[ii] = np.int32(packet[offset + 6])
+        toff = np.float64(packet[offset + 2]) + np.float64(packet[offset + 3]) * 1e-6
 
         # set the dead region values
         offset += 7
-        tbl['dr_start_pps'].nda[ii]   = np.int32(packet[offset])
-        tbl['dr_start_ticks'].nda[ii] = np.int32(packet[offset+1])
-        tbl['dr_stop_pps'].nda[ii]    = np.int32(packet[offset+2])
-        tbl['dr_stop_ticks'].nda[ii]  = np.int32(packet[offset+3])
-        tbl['dr_maxticks'].nda[ii]    = np.int32(packet[offset+4])
+        tbl["dr_start_pps"].nda[ii] = np.int32(packet[offset])
+        tbl["dr_start_ticks"].nda[ii] = np.int32(packet[offset + 1])
+        tbl["dr_stop_pps"].nda[ii] = np.int32(packet[offset + 2])
+        tbl["dr_stop_ticks"].nda[ii] = np.int32(packet[offset + 3])
+        tbl["dr_maxticks"].nda[ii] = np.int32(packet[offset + 4])
 
         # set the event number and clock counters
         offset += 5
-        tbl['eventnumber'].nda[ii]         = np.int32(packet[offset])
-        tbl['ts_pps'].nda[ii]       = np.int32(packet[offset+1])
-        tbl['ts_ticks'].nda[ii]     = np.int32(packet[offset+2])
-        tbl['ts_maxticks'].nda[ii]  = np.int32(packet[offset+3])
+        tbl["eventnumber"].nda[ii] = np.int32(packet[offset])
+        tbl["ts_pps"].nda[ii] = np.int32(packet[offset + 1])
+        tbl["ts_ticks"].nda[ii] = np.int32(packet[offset + 2])
+        tbl["ts_maxticks"].nda[ii] = np.int32(packet[offset + 3])
 
         # set the runtime and timestamp
-        tstamp = np.float64(packet[offset+1]);
-        tstamp += np.float64(packet[offset+2]) / (np.int32(packet[offset+3])+1)
-        tbl['runtime'].nda[ii]   = tstamp
-        tbl['timestamp'].nda[ii] = tstamp + toff
+        tstamp = np.float64(packet[offset + 1])
+        tstamp += np.float64(packet[offset + 2]) / (np.int32(packet[offset + 3]) + 1)
+        tbl["runtime"].nda[ii] = tstamp
+        tbl["timestamp"].nda[ii] = tstamp + toff
 
         # set the fpga baseline/energy and waveform
         offset = orca_header_length + fcio_header_length
-        tbl['baseline'].nda[ii] =  packet[offset-1] & 0x0000ffff
-        tbl['daqenergy'].nda[ii]   = (packet[offset-1] & 0xffff0000) >> 16
-        wf = np.frombuffer(packet, dtype=np.uint16)[offset*2:
-                                                    offset*2 + wf_samples]
+        tbl["baseline"].nda[ii] = packet[offset - 1] & 0x0000FFFF
+        tbl["daqenergy"].nda[ii] = (packet[offset - 1] & 0xFFFF0000) >> 16
+        wf = np.frombuffer(packet, dtype=np.uint16)[
+            offset * 2 : offset * 2 + wf_samples
+        ]
 
-        tbl['waveform']['values'].nda[ii][:wf_samples] = wf
+        tbl["waveform"]["values"].nda[ii][:wf_samples] = wf
 
         evt_rbkd[key].loc += 1
         return evt_rbkd[key].is_full()
diff --git a/src/pygama/raw/orca/orca_header.py b/src/pygama/raw/orca/orca_header.py
index e96e10a0f..630556662 100644
--- a/src/pygama/raw/orca/orca_header.py
+++ b/src/pygama/raw/orca/orca_header.py
@@ -1,83 +1,88 @@
+from __future__ import annotations
+
 import json
-import plistlib
+import logging
+
+from pygama import lgdo
+
+log = logging.getLogger(__name__)
 
 
 class OrcaHeader(dict):
-    """
-    Orca file header object
-    """
-    def __init__(self, jsons=None, lgdo_scalar=None):
-        if jsons is not None: self.update(json.loads(jsons))
-        elif lgdo_scalar is not None: self.set_from_lgdo(lgdo_scalar)
+    """ORCA file header object."""
 
+    def __init__(self, jsons: str = None, lgdo_scalar: lgdo.Scalar = None) -> None:
+        if jsons is not None:
+            self.update(json.loads(jsons))
+        elif lgdo_scalar is not None:
+            self.set_from_lgdo(lgdo_scalar)
 
-    def set_from_lgdo(self, lgdo_scalar):
+    def set_from_lgdo(self, lgdo_scalar: lgdo.Scalar) -> None:
         if not isinstance(lgdo_scalar, lgdo.Scalar):
-            print(f"Error: can't instantiate a header from a {type(lgdo_scalar)}.")
-        else: self.update(json.loads(lgdo_scalar.value))
+            raise ValueError(f"can't instantiate a header from a {type(lgdo_scalar)}")
+        else:
+            self.update(json.loads(lgdo_scalar.value))
 
-
-    def get_decoder_list(self):
+    def get_decoder_list(self) -> list[str]:
         decoder_names = []
-        dd = self['dataDescription']
+        dd = self["dataDescription"]
         for class_key in dd.keys():
             for super_key in dd[class_key].keys():
-                decoder_names.append(dd[class_key][super_key]['decoder'])
+                decoder_names.append(dd[class_key][super_key]["decoder"])
         return decoder_names
 
-
-    def get_data_id(self, decoder_name):
-        dd = self['dataDescription']
+    def get_data_id(self, decoder_name: str) -> int:
+        dd = self["dataDescription"]
         for class_key in dd.keys():
             for super_key in dd[class_key].keys():
-                if dd[class_key][super_key]['decoder'] == decoder_name:
-                    return dd[class_key][super_key]['dataId']
+                if dd[class_key][super_key]["decoder"] == decoder_name:
+                    return dd[class_key][super_key]["dataId"]
 
-    def get_id_to_object_name_dict(self, shift_data_id=True):
+    def get_id_to_object_name_dict(self, shift_data_id: bool = True) -> dict[int, str]:
         id_dict = {}
-        dd = self['dataDescription']
+        dd = self["dataDescription"]
         for class_key in dd.keys():
             for super_key in dd[class_key].keys():
-                data_id = dd[class_key][super_key]['dataId']
+                data_id = dd[class_key][super_key]["dataId"]
                 if shift_data_id:
-                    if data_id < 0: # short record
+                    if data_id < 0:  # short record
                         data_id = (-data_id) >> 26
-                    else: data_id = data_id >> 18
-                id_dict[data_id] = f'{class_key}:{super_key}'
+                    else:
+                        data_id = data_id >> 18
+                id_dict[data_id] = f"{class_key}:{super_key}"
         return id_dict
 
-    def get_run_number(self):
+    def get_run_number(self) -> int:
         for d in self["ObjectInfo"]["DataChain"]:
-            if "Run Control" in d: return d["Run Control"]["RunNumber"]
+            if "Run Control" in d:
+                return d["Run Control"]["RunNumber"]
         raise ValueError("No run number found in header!")
 
-
-    def get_object_info(self, orca_class_name):
-        """
-        returns a dict[crate#][card#] with all info from the header for each card with name
-        orca_class_name.
+    def get_object_info(self, orca_class_name: str) -> dict[int, dict[int, dict]]:
+        """Returns a ``dict[crate][card]`` with all info from the header for
+        each card with name `orca_class_name`.
         """
         object_info_dict = {}
 
         crates = self["ObjectInfo"]["Crates"]
         for crate in crates:
-            object_info_dict[crate['CrateNumber']] = {}
+            object_info_dict[crate["CrateNumber"]] = {}
             cards = crate["Cards"]
             for card in cards:
                 if card["Class Name"] == orca_class_name:
-                    object_info_dict[crate['CrateNumber']][card['Card']] = card
+                    object_info_dict[crate["CrateNumber"]][card["Card"]] = card
 
         if len(object_info_dict) == 0:
-            raise KeyError(f'no object info for class {orca_class_name}')
+            raise KeyError(f"no object info for class {orca_class_name}")
 
         return object_info_dict
 
+    def get_readout_info(self, orca_class_name: str, unique_id: int = -1) -> list:
+        """Returns a list with all the readout list info from the header with
+        name `orca_class_name`.
 
-    def get_readout_info(self, orca_class_name, unique_id=-1):
-        """
-        returns a list with all the readout list info from the header with name
-        orca_class_name.  optionally, if unique_id >= 0 only return the
-        readout info for that Orca unique id number.
+        Optionally, if `unique_id` is greater or equal than zero, only return
+        the readout info for that ORCA unique ID number.
         """
         readout_info_list = []
         try:
@@ -85,21 +90,23 @@ def get_readout_info(self, orca_class_name, unique_id=-1):
             for readout in readouts:
                 try:
                     if readout["name"] == orca_class_name:
-                        if unique_id >= 0 and obj["uniqueID"] == unique_id: return readout
+                        if unique_id >= 0 and readout["uniqueID"] == unique_id:
+                            return readout
                         readout_info_list.append(readout)
-                except KeyError: continue
-        except KeyError: pass
+                except KeyError:
+                    continue
+        except KeyError:
+            pass
         if len(readout_info_list) == 0:
-            print('OrcaHeader::get_readout_info(): warning: no readout info '
-              'for class name', orca_class_name)
+            log.warning(f"no readout info for class name '{orca_class_name}'")
         return readout_info_list
 
+    def get_auxhw_info(self, orca_class_name: str, unique_id: int = -1) -> list:
+        """Returns a list with all the info from the ``AuxHw`` table of the header
+        with name `orca_class_name`.
 
-    def get_auxhw_info(self, orca_class_name, unique_id=-1):
-        """
-        returns a list with all the info from the AuxHw table of the header
-        with name orca_class_name.  optionally, if unique_id >= 0 only return
-        the object for that Orca unique id number.
+        Optionally, if `unique_id` is greater or equal to zero, only return the
+        object for that ORCA unique ID number.
         """
         auxhw_info_list = []
         try:
@@ -107,11 +114,13 @@ def get_auxhw_info(self, orca_class_name, unique_id=-1):
             for obj in objs:
                 try:
                     if obj["Class Name"] == orca_class_name:
-                        if unique_id >= 0 and obj["uniqueID"] == unique_id: return obj
+                        if unique_id >= 0 and obj["uniqueID"] == unique_id:
+                            return obj
                         auxhw_info_list.append(obj)
-                except KeyError: continue
-        except KeyError: pass
+                except KeyError:
+                    continue
+        except KeyError:
+            pass
         if len(auxhw_info_list) == 0:
-            print('OrcaHeader::get_auxhw_info(): warning: no object info '
-                  'for class name', orca_class_name)
+            log.warning(f"no object info for class name '{orca_class_name}'")
         return auxhw_info_list
diff --git a/src/pygama/raw/orca/orca_header_decoder.py b/src/pygama/raw/orca/orca_header_decoder.py
index 87a4f4082..04fdda94d 100644
--- a/src/pygama/raw/orca/orca_header_decoder.py
+++ b/src/pygama/raw/orca/orca_header_decoder.py
@@ -1,37 +1,49 @@
+from __future__ import annotations
+
+import json
+import logging
 import plistlib
 
 from pygama import lgdo
+from pygama.raw.orca.orca_base import OrcaDecoder
+from pygama.raw.orca.orca_header import OrcaHeader
+from pygama.raw.orca.orca_packet import OrcaPacket
+from pygama.raw.raw_buffer import RawBuffer, RawBufferLibrary
 
-from . import orca_header
-from .orca_base import OrcaDecoder
+log = logging.getLogger(__name__)
 
 
 class OrcaHeaderDecoder(OrcaDecoder):
-    """
-    Decodes Orca headers
-    """
-    def __init__(self, header=None, **kwargs):
+    """Decodes ORCA headers."""
+
+    def __init__(self, header: OrcaHeader = None, **kwargs) -> None:
         if header is not None:
-            print("Warning: OrcaHeaderDecoder: unexpected non-None header in __init__")
+            log.warning("unexpected non-None header")
         super().__init__(header=header, **kwargs)
-        self.header = orca_header.OrcaHeader()
+        self.header = OrcaHeader()
 
-    def make_lgdo(self, key=None, size=None): return lgdo.Scalar(value='')
+    def make_lgdo(self, key: int = None, size: int = None) -> lgdo.Scalar:
+        return lgdo.Scalar(value="")
 
-    def buffer_is_full(self, rb): return rb.loc > 0
+    def buffer_is_full(self, rb: RawBuffer) -> bool:
+        return rb.loc > 0
 
-    def decode_packet(self, packet, packet_id, rbl=None, verbosity=0):
+    def decode_packet(
+        self, packet: OrcaPacket, packet_id: int, rbl: RawBufferLibrary = None
+    ) -> bool:
         if len(self.header.keys()) != 0:
-            print("Warning: got a second header...")
+            log.warning("got an unexpected second header")
         if packet_id != 0:
-            print(f"Warning: header was packet {packet_id}")
-        n_bytes = packet[1] # word 1 is the header packet length in bytes
+            log.warning(f"header was packet number {packet_id}")
+        n_bytes = packet[1]  # word 1 is the header packet length in bytes
         as_bytes = packet[2:].tobytes()
         self.header.update(plistlib.loads(as_bytes[:n_bytes], fmt=plistlib.FMT_XML))
         if rbl is not None:
             if len(rbl) != 1 or not isinstance(rbl[0].lgdo, lgdo.Scalar):
-                print(f"Error: len(rbl) = {len(rbl)}, type = {type(rbl[0].lgdo)}")
+                raise ValueError(
+                    f"invalid RawBufferLibrary object: len(rbl) = {len(rbl)}, type = {type(rbl[0].lgdo)}"
+                )
             else:
                 rbl[0].lgdo.value = json.dumps(self.header)
                 rbl[0].loc = 1
-        return True # header raw buffer is "full"
+        return True  # header raw buffer is "full"
diff --git a/src/pygama/raw/orca/orca_packet.py b/src/pygama/raw/orca/orca_packet.py
index 6cde30ff6..6f757770b 100644
--- a/src/pygama/raw/orca/orca_packet.py
+++ b/src/pygama/raw/orca/orca_packet.py
@@ -1,49 +1,75 @@
-'''
-orca_packet provides convenience functions for working with orca packets.
+"""
+Provides convenience functions for working with ORCA packets.
 
-orca packets are ndarrays of type uint32
-1 'word' is one uint32
-'''
+An ORCA packet is represented by a one-dimensional :class:`numpy.ndarray` of
+type :class:`numpy.uint32`.
+"""
+from __future__ import annotations
+
+import logging
 
 import numpy as np
+import numpy.typing as npt
+
+log = logging.getLogger(__name__)
+OrcaPacket = npt.NDArray[np.uint32]
 
 
-def is_short(packet):
+def is_short(packet: OrcaPacket) -> bool:
     return bool(packet[0] >> 31)
 
-def get_n_words(packet):
-    if is_short(packet): return 1
+
+def get_n_words(packet: OrcaPacket) -> int:
+    if is_short(packet):
+        return 1
     return packet[0] & 0x3FFFF
 
-def get_data_id(packet, shift=True):
+
+def get_data_id(packet: OrcaPacket, shift: bool = True) -> np.uint32:
     # in the header the data_id appears unshifted, but in pygama we use it
     # shifted by default so that it has sensible values when printed / checked
     if is_short(packet):
-        if shift: return (packet[0] & 0xFC000000) >> 26
-        else: return packet[0] & 0xFC000000
-    if shift: return (packet[0] & 0xFFFC0000) >> 18
+        if shift:
+            return (packet[0] & 0xFC000000) >> 26
+        else:
+            return packet[0] & 0xFC000000
+    if shift:
+        return (packet[0] & 0xFFFC0000) >> 18
     return packet[0] & 0xFFFC0000
 
 
-def hex_dump(packet, shift_data_id=True, print_n_words=False, max_words=np.inf,
-             as_int=False, as_short=False, id_dict=None):
+def hex_dump(
+    packet: OrcaPacket,
+    shift_data_id: bool = True,
+    print_n_words: bool = False,
+    max_words: int = np.inf,
+    as_int: bool = False,
+    as_short: bool = False,
+    id_dict: dict = None,
+) -> None:
+
     data_id = get_data_id(packet, shift=shift_data_id)
     n_words = get_n_words(packet)
     if id_dict is not None:
         if data_id in id_dict:
-            heading = f'{id_dict[data_id]} (data ID = {data_id})'
+            heading = f"{id_dict[data_id]} (data ID = {data_id})"
         else:
-            heading = f'[unknown] (data ID = {data_id})'
-    else: heading = f'data ID = {data_id}'
-    if print_n_words: print(f'{heading}: {n_words} words')
+            heading = f"[unknown] (data ID = {data_id})"
+    else:
+        heading = f"data ID = {data_id}"
+    if print_n_words:
+        log.debug(f"{heading}: {n_words} words")
     else:
-        print(f'{heading}:')
+        log.debug(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):
-            line = f'{str(i).zfill(pad)}'
-            line += ' {0:#0{1}x}'.format(packet[i], 10)
-            if data_id == 0 and i > 1: line += f' {packet[i:i+1].tobytes().decode()}'
-            if as_int: line += f' {packet[i]}'
-            if as_short: line += f" {np.frombuffer(packet[i:i+1].tobytes(), dtype='uint16')}"
-            print(line)
+            line = f"{str(i).zfill(pad)}"
+            line += " {0:#0{1}x}".format(packet[i], 10)
+            if data_id == 0 and i > 1:
+                line += f" {packet[i:i+1].tobytes().decode()}"
+            if as_int:
+                line += f" {packet[i]}"
+            if as_short:
+                line += f" {np.frombuffer(packet[i:i+1].tobytes(), dtype='uint16')}"
+            log.debug(line)
diff --git a/src/pygama/raw/orca/orca_streamer.py b/src/pygama/raw/orca/orca_streamer.py
index 8186e335b..9deb5e87e 100644
--- a/src/pygama/raw/orca/orca_streamer.py
+++ b/src/pygama/raw/orca/orca_streamer.py
@@ -1,157 +1,190 @@
+from __future__ import annotations
+
 import gzip
 import json
 import logging
 
 import numpy as np
 
-from ..data_streamer import DataStreamer
-from ..raw_buffer import RawBuffer
-from . import orca_packet
-from .orca_flashcam import ORFlashCamADCWaveformDecoder, ORFlashCamListenerConfigDecoder
-from .orca_header_decoder import OrcaHeaderDecoder
+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_flashcam import (  # noqa: F401
+    ORFlashCamADCWaveformDecoder,
+    ORFlashCamListenerConfigDecoder,
+)
+from pygama.raw.orca.orca_header_decoder import OrcaHeaderDecoder
+from pygama.raw.raw_buffer import RawBuffer, RawBufferLibrary
 
 log = logging.getLogger(__name__)
 
 
 class OrcaStreamer(DataStreamer):
-    """ Data streamer for ORCA data
-    """
-    def __init__(self):
+    """Data streamer for ORCA data."""
+
+    def __init__(self) -> None:
         super().__init__()
         self.in_stream = None
-        self.buffer = np.empty(1024, dtype='uint32') # start with a 4 kB packet buffer
+        self.buffer = np.empty(1024, dtype="uint32")  # start with a 4 kB packet buffer
         self.header = None
         self.header_decoder = OrcaHeaderDecoder()
-        self.decoder_id_dict = {} # dict of data_id to decoder object
-        self.decoder_name_dict = {} # dict of name to decoder object
-        self.rbl_id_dict = {} # dict of RawBufferLists for each data_id
+        self.decoder_id_dict = {}  # dict of data_id to decoder object
+        self.decoder_name_dict = {}  # dict of name to decoder object
+        self.rbl_id_dict = {}  # dict of RawBufferLists for each data_id
 
+    # TODO: need to correct for endianness?
+    def load_packet(self, skip_unknown_ids: bool = False) -> np.uint32 | None:
+        """Loads the next packet into the internal buffer.
 
-    def load_packet(self, skip_unknown_ids=False):
-        """ Loads the next packet into the internal buffer
-        Returns packet as a uint32 view of the buffer (a slice)
-        Returns None at EOF or for an error
-
-        CHECK: need to correct for endianness?
+        Returns packet as a :class:`numpy.uint32` view of the buffer (a slice),
+        returns ``None`` at EOF.
         """
         if self.in_stream is None:
-            print('Error: in_stream is None')
-            return None
+            raise RuntimeError("self.in_stream is None")
 
         # read packet header
         pkt_hdr = self.buffer[:1]
-        n_bytes_read = self.in_stream.readinto(pkt_hdr) # buffer is at least 4 kB long
+        n_bytes_read = self.in_stream.readinto(pkt_hdr)  # buffer is at least 4 kB long
         self.n_bytes_read += n_bytes_read
-        if n_bytes_read == 0: return None
-        if n_bytes_read != 4:
-            print(f'Error: only got {n_bytes_read} bytes for packet header')
+        if n_bytes_read == 0:
             return None
+        if n_bytes_read != 4:
+            raise RuntimeError(f"only got {n_bytes_read} bytes for packet header")
 
         # if it's a short packet, we are done
-        if orca_packet.is_short(pkt_hdr): return pkt_hdr
+        if orca_packet.is_short(pkt_hdr):
+            return pkt_hdr
 
         # long packet: get length and check if we can skip it
         n_words = orca_packet.get_n_words(pkt_hdr)
-        if skip_unknown_ids and orca_packet.get_data_id(pkt_hdr, shift=False) not in self.decoder_id_dict:
-            self.in_stream.seek((n_words-1)*4, 1)
-            self.n_bytes_read += (n_words-1)*4 # well, we didn't really read it...
+        if (
+            skip_unknown_ids
+            and orca_packet.get_data_id(pkt_hdr, shift=False)
+            not in self.decoder_id_dict
+        ):
+            self.in_stream.seek((n_words - 1) * 4, 1)
+            self.n_bytes_read += (n_words - 1) * 4  # well, we didn't really read it...
             return pkt_hdr
 
         # load into buffer, resizing as necessary
-        if len(self.buffer) < n_words: self.buffer.resize(n_words, refcheck=False)
+        if len(self.buffer) < n_words:
+            self.buffer.resize(n_words, refcheck=False)
         n_bytes_read = self.in_stream.readinto(self.buffer[1:n_words])
         self.n_bytes_read += n_bytes_read
-        if n_bytes_read != (n_words-1)*4:
-            print(f'Error: only got {n_bytes_read} bytes for packet read when {(n_words-1)*4} were expected.')
-            return None
+        if n_bytes_read != (n_words - 1) * 4:
+            raise RuntimeError(
+                f"only got {n_bytes_read} bytes for packet read when {(n_words-1)*4} were expected."
+            )
 
         # return just the packet
         return self.buffer[:n_words]
 
-
-    def get_decoder_list(self):
+    def get_decoder_list(self) -> list[OrcaDecoder]:
         return list(self.decoder_id_dict.values())
 
-
-    def set_in_stream(self, stream_name):
-        if self.in_stream is not None: self.close_in_stream()
-        if stream_name.endswith('.gz'):
-            self.in_stream = gzip.open(stream_name.encode('utf-8'), 'rb')
-        else: self.in_stream = open(stream_name.encode('utf-8'), 'rb')
+    def set_in_stream(self, stream_name: str) -> None:
+        if self.in_stream is not None:
+            self.close_in_stream()
+        if stream_name.endswith(".gz"):
+            self.in_stream = gzip.open(stream_name.encode("utf-8"), "rb")
+        else:
+            self.in_stream = open(stream_name.encode("utf-8"), "rb")
         self.n_bytes_read = 0
 
-
-    def close_in_stream(self):
+    def close_in_stream(self) -> None:
         if self.in_stream is None:
-            raise RuntimeWarning("tried to close an unopened stream")
+            raise RuntimeError("tried to close an unopened stream")
         self.in_stream.close()
         self.in_stream = None
 
-    def close_stream(self): self.close_in_stream()
+    def close_stream(self) -> None:
+        self.close_in_stream()
 
-    def is_orca_stream(stream_name): # static function
+    def is_orca_stream(stream_name: str) -> bool:  # noqa: N805
         orca = OrcaStreamer()
         orca.set_in_stream(stream_name)
         first_bytes = orca.in_stream.read(12)
         orca.close_in_stream()
 
         # that read should have succeeded
-        if len(first_bytes) != 12: return False
+        if len(first_bytes) != 12:
+            return False
 
         # first 14 bits should be zero
-        uints = np.frombuffer(first_bytes, dtype='uint32')
-        if (uints[0] & 0xfffc0000) != 0: return False
+        uints = np.frombuffer(first_bytes, dtype="uint32")
+        if (uints[0] & 0xFFFC0000) != 0:
+            return False
 
         # xml header length should fit within header packet length
         pad = uints[0] * 4 - 8 - uints[1]
-        if pad < 0 or pad > 3: return False
+        if pad < 0 or pad > 3:
+            return False
 
         # last 4 chars should be '<?xm'
-        if first_bytes[8:].decode() != '<?xm': return False
+        if first_bytes[8:].decode() != "<?xm":
+            return False
 
         # it must be an orca stream
         return True
 
-
-    def hex_dump(self, stream_name, n_packets=np.inf,
-                 skip_header=False, shift_data_id=True, print_n_words=False,
-                 max_words=np.inf, as_int=False, as_short=False):
+    def hex_dump(
+        self,
+        stream_name: str,
+        n_packets: int = np.inf,
+        skip_header: bool = False,
+        shift_data_id: bool = True,
+        print_n_words: bool = False,
+        max_words: int = np.inf,
+        as_int: bool = False,
+        as_short: bool = False,
+    ) -> None:
         self.set_in_stream(stream_name)
-        if skip_header: self.load_packet()
+        if skip_header:
+            self.load_packet()
         while n_packets > 0:
             packet = self.load_packet()
             if packet is None:
                 self.close_in_stream()
                 return
-            orca_packet.hex_dump(packet, shift_data_id=shift_data_id,
-                                 print_n_words=print_n_words,
-                                 max_words=max_words, as_int=as_int,
-                                 as_short=as_short)
+            orca_packet.hex_dump(
+                packet,
+                shift_data_id=shift_data_id,
+                print_n_words=print_n_words,
+                max_words=max_words,
+                as_int=as_int,
+                as_short=as_short,
+            )
             n_packets -= 1
 
-
-    def open_stream(self, stream_name, rb_lib=None, buffer_size=8192,
-                    chunk_mode='any_full', out_stream=''):
-        """ Initialize the ORCA data stream
+    def open_stream(
+        self,
+        stream_name: str,
+        rb_lib: RawBufferLibrary = None,
+        buffer_size: int = 8192,
+        chunk_mode: str = "any_full",
+        out_stream: str = "",
+    ) -> list[RawBuffer]:
+        """Initialize the ORCA data stream.
 
         Parameters
         ----------
-        stream_name : str
-            The ORCA filename.  Only file streams are currently supported.
+        stream_name
+            The ORCA filename. Only file streams are currently supported.
             Socket stream reading can be added later.
-        rb_lib : RawBufferLibrary
-            library of buffers for this stream
-        buffer_size : int
-            length of tables to be read out in read_chunk
+        rb_lib
+            library of buffers for this stream.
+        buffer_size
+            length of tables to be read out in :meth:`read_chunk`.
         chunk_mode : 'any_full', 'only_full', or 'single_packet'
-            sets the mode use for read_chunk
-        out_stream : str
-            optional name of output stream for default rb_lib generation
+            sets the mode use for :meth:`read_chunk`.
+        out_stream
+            optional name of output stream for default `rb_lib` generation.
 
         Returns
         -------
-        header_data : list(RawBuffer)
-            a list of length 1 containing the raw buffer holding the ORCA header
+        header_data
+            a list of length 1 containing the raw buffer holding the ORCA header.
         """
 
         self.set_in_stream(stream_name)
@@ -159,33 +192,40 @@ def open_stream(self, stream_name, rb_lib=None, buffer_size=8192,
         # read in the header
         packet = self.load_packet()
         if orca_packet.get_data_id(packet) != 0:
-            print(f'Error: got data id {orca_packet.get_data_id(packet)} for header')
-            return []
+            raise RuntimeError(
+                f"got data id {orca_packet.get_data_id(packet)} for header"
+            )
+
         self.packet_id = 0
         self.any_full |= self.header_decoder.decode_packet(packet, self.packet_id)
         self.header = self.header_decoder.header
 
         # instantiate decoders listed in the header AND in the rb_lib (if specified)
-        decoder_names = ['OrcaHeaderDecoder']
+        decoder_names = ["OrcaHeaderDecoder"]
         decoder_names += self.header.get_decoder_list()
-        if rb_lib is not None and '*' not in rb_lib:
+        if rb_lib is not None and "*" not in rb_lib:
             keep_decoders = []
             for name in decoder_names:
-                if name in rb_lib: keep_decoders.append(name)
+                if name in rb_lib:
+                    keep_decoders.append(name)
             decoder_names = keep_decoders
             # check that all requested decoders are present
             for name in rb_lib.keys():
                 if name not in keep_decoders:
-                    print(f'Warning: decoder {name} (requested in rb_lib) not in data description in header')
+                    log.warning(
+                        f"decoder {name} (requested in rb_lib) not in data description in header"
+                    )
         for name in decoder_names:
             # handle header decoder specially
-            if name == 'OrcaHeaderDecoder':
+            if name == "OrcaHeaderDecoder":
                 self.decoder_id_dict[0] = self.header_decoder
-                self.decoder_name_dict['OrcaHeaderDecoder'] = 0
+                self.decoder_name_dict["OrcaHeaderDecoder"] = 0
                 continue
             # instantiate other decoders by name
             if name not in globals():
-                print(f'Warning: No implementation of {name}, corresponding packets will be skipped')
+                log.warning(
+                    f"no implementation of {name}, corresponding packets will be skipped"
+                )
                 continue
             decoder = globals()[name]
             decoder.data_id = self.header.get_data_id(name)
@@ -193,41 +233,51 @@ def open_stream(self, stream_name, rb_lib=None, buffer_size=8192,
             self.decoder_name_dict[name] = decoder.data_id
 
         # initialize the buffers in rb_lib. Store them for fast lookup
-        super().open_stream(stream_name, rb_lib, buffer_size=buffer_size,
-                            chunk_mode=chunk_mode, out_stream=out_stream)
-        if rb_lib is None: rb_lib = self.rb_lib
+        super().open_stream(
+            stream_name,
+            rb_lib,
+            buffer_size=buffer_size,
+            chunk_mode=chunk_mode,
+            out_stream=out_stream,
+        )
+        if rb_lib is None:
+            rb_lib = self.rb_lib
         for name in self.rb_lib.keys():
             data_id = self.decoder_name_dict[name]
             self.rbl_id_dict[data_id] = self.rb_lib[name]
         log.debug(f"rb_lib = {self.rb_lib}")
 
         # return header raw buffer
-        if 'OrcaHeaderDecoder' in rb_lib:
-            header_rb_list = rb_lib['OrcaHeaderDecoder']
+        if "OrcaHeaderDecoder" in rb_lib:
+            header_rb_list = rb_lib["OrcaHeaderDecoder"]
             if len(header_rb_list) != 1:
-                print(f'warning! header_rb_list had length {len(header_rb_list)}, ignoring all but the first')
+                log.warning(
+                    f"header_rb_list had length {len(header_rb_list)}, ignoring all but the first"
+                )
             rb = header_rb_list[0]
-        else: rb = RawBuffer(lgdo=self.header_decoder.make_lgdo())
+        else:
+            rb = RawBuffer(lgdo=self.header_decoder.make_lgdo())
         rb.lgdo.value = json.dumps(self.header)
-        rb.loc = 1 # we have filled this buffer
+        rb.loc = 1  # we have filled this buffer
         return [rb]
 
+    def read_packet(self) -> bool:
+        """Read a packet of data.
 
-    def read_packet(self):
-        """ Read a packet of data.
-
-        Data written to self.rb_lib.
+        Data written to the `rb_lib` attribute.
         """
         # read until we get a decodeable packet
         while True:
             packet = self.load_packet(skip_unknown_ids=True)
-            if packet is None: return False
+            if packet is None:
+                return False
             self.packet_id += 1
 
             # look up the data id, decoder, and rbl
             data_id = orca_packet.get_data_id(packet, shift=False)
-            log.debug(f'packet {self.packet_id}: data_id = {data_id}')
-            if data_id in self.decoder_id_dict: break
+            log.debug(f"packet {self.packet_id}: data_id = {data_id}")
+            if data_id in self.decoder_id_dict:
+                break
 
         # now decode
         decoder = self.decoder_id_dict[data_id]
diff --git a/src/pygama/raw/raw_buffer.py b/src/pygama/raw/raw_buffer.py
index 5f70c6683..96a0ab87b 100644
--- a/src/pygama/raw/raw_buffer.py
+++ b/src/pygama/raw/raw_buffer.py
@@ -101,19 +101,25 @@ class RawBuffer:
         the name or identifier of the object in the output stream.
     """
 
-
-    def __init__(self, lgdo: LGDO = None, key_list: list[int | str] = [],
-                 out_stream: str = '', out_name: str = '') -> None:
+    def __init__(
+        self,
+        lgdo: LGDO = None,
+        key_list: list[int | str] = None,
+        out_stream: str = "",
+        out_name: str = "",
+    ) -> None:
         self.lgdo = lgdo
-        self.key_list = key_list
+        self.key_list = [] if key_list is None else key_list
         self.out_stream = out_stream
         self.out_name = out_name
         self.loc = 0
         self.fill_safety = 1
 
     def __len__(self) -> int:
-        if self.lgdo is None: return 0
-        if not hasattr(self.lgdo, '__len__'): return 1
+        if self.lgdo is None:
+            return 0
+        if not hasattr(self.lgdo, "__len__"):
+            return 1
         return len(self.lgdo)
 
     def is_full(self) -> bool:
@@ -130,6 +136,7 @@ class RawBufferList(list):
     r"""A :class:`.RawBufferList` holds a collection of :class:`.RawBuffer`\ s
     of identical structure (same format LGDO's with the same fields).
     """
+
     def __init__(self) -> None:
         self.keyed_dict = None
 
@@ -143,10 +150,13 @@ def get_keyed_dict(self) -> dict[int | str, RawBuffer]:
         if self.keyed_dict is None:
             self.keyed_dict = {}
             for rb in self:
-                for key in rb.key_list: self.keyed_dict[key] = rb
+                for key in rb.key_list:
+                    self.keyed_dict[key] = rb
         return self.keyed_dict
 
-    def set_from_json_dict(self, json_dict: dict, kw_dict: dict[str, str] = {}) -> None:
+    def set_from_json_dict(
+        self, json_dict: dict, kw_dict: dict[str, str] = None
+    ) -> None:
         """Set up a :class:`.RawBufferList` from a dictionary written in JSON
         shorthand. See :meth:`.RawBufferLibrary.set_from_json_dict` for details.
 
@@ -154,16 +164,17 @@ def set_from_json_dict(self, json_dict: dict, kw_dict: dict[str, str] = {}) -> N
         -----
         `json_dict` is changed by this function.
         """
-        expand_rblist_json_dict(json_dict, kw_dict)
+        expand_rblist_json_dict(json_dict, {} if kw_dict is None else kw_dict)
         for name in json_dict:
             rb = RawBuffer()
-            if 'key_list' in json_dict[name]:
-                rb.key_list = json_dict[name]['key_list']
-            if 'out_stream' in json_dict[name]:
-                rb.out_stream = json_dict[name]['out_stream']
-            if 'out_name' in json_dict[name]:
-                rb.out_name = json_dict[name]['out_name']
-            else: rb.out_name = name
+            if "key_list" in json_dict[name]:
+                rb.key_list = json_dict[name]["key_list"]
+            if "out_stream" in json_dict[name]:
+                rb.out_stream = json_dict[name]["out_stream"]
+            if "out_name" in json_dict[name]:
+                rb.out_name = json_dict[name]["out_name"]
+            else:
+                rb.out_name = name
             self.append(rb)
 
     def get_list_of(self, attribute: str) -> list:
@@ -185,14 +196,17 @@ def get_list_of(self, attribute: str) -> list:
         """
         values = []
         for rb in self:
-            if not hasattr(rb, attribute): continue
+            if not hasattr(rb, attribute):
+                continue
             val = getattr(rb, attribute)
-            if val not in values: values.append(val)
+            if val not in values:
+                values.append(val)
         return values
 
     def clear_full(self) -> None:
         for rb in self:
-            if rb.is_full(): rb.loc = 0
+            if rb.is_full():
+                rb.loc = 0
 
 
 class RawBufferLibrary(dict):
@@ -200,11 +214,14 @@ class RawBufferLibrary(dict):
     :class:`.RawBufferList`\ s associated with the names of decoders that can
     write to them.
     """
-    def __init__(self, json_dict: dict = None, kw_dict: dict[str, str] = {}) -> None:
+
+    def __init__(self, json_dict: dict = None, kw_dict: dict[str, str] = None) -> None:
         if json_dict is not None:
             self.set_from_json_dict(json_dict, kw_dict)
 
-    def set_from_json_dict(self, json_dict: dict, kw_dict: dict[str, str] = {}) -> None:
+    def set_from_json_dict(
+        self, json_dict: dict, kw_dict: dict[str, str] = None
+    ) -> None:
         r"""Set up a :class:`.RawBufferLibrary` from a dictionary written in
         JSON shorthand.
 
@@ -258,8 +275,11 @@ def set_from_json_dict(self, json_dict: dict, kw_dict: dict[str, str] = {}) -> N
             ``out_stream`` and ``out_name`` fields.
         """
         for list_name in json_dict:
-            if list_name not in self: self[list_name] = RawBufferList()
-            self[list_name].set_from_json_dict(json_dict[list_name], kw_dict)
+            if list_name not in self:
+                self[list_name] = RawBufferList()
+            self[list_name].set_from_json_dict(
+                json_dict[list_name], {} if kw_dict is None else kw_dict
+            )
 
     def get_list_of(self, attribute: str, unique: bool = True) -> list:
         """Return a list of values of :class:`.RawBuffer` attributes.
@@ -283,11 +303,13 @@ def get_list_of(self, attribute: str, unique: bool = True) -> list:
         values = []
         for rb_list in self.values():
             values += rb_list.get_list_of(attribute)
-        if unique: values = list(set(values))
+        if unique:
+            values = list(set(values))
         return values
 
     def clear_full(self) -> None:
-        for rb_list in self.values(): rb_list.clear_full()
+        for rb_list in self.values():
+            rb_list.clear_full()
 
 
 def expand_rblist_json_dict(json_dict: dict, kw_dict: dict[str, str]) -> None:
@@ -306,12 +328,12 @@ def expand_rblist_json_dict(json_dict: dict, kw_dict: dict[str, str]) -> None:
     # the dictionary during iteration
     buffer_names = list(json_dict.keys())
     for name in buffer_names:
-        if name == '':
+        if name == "":
             raise ValueError("buffer name can't be empty")
 
-        info = json_dict[name] # changes to info will change json_dict[name]
+        info = json_dict[name]  # changes to info will change json_dict[name]
         # make sure we have a key list
-        if 'key_list' not in info:
+        if "key_list" not in info:
             raise ValueError(f"'{name}' is missing key_list")
             continue
 
@@ -319,39 +341,43 @@ def expand_rblist_json_dict(json_dict: dict, kw_dict: dict[str, str]) -> None:
         # do in a while loop with a controlled index since we are modifying
         # the length of the list inside the loop (be careful)
         i = 0
-        while i < len(info['key_list']):
-            key_range = info['key_list'][i]
+        while i < len(info["key_list"]):
+            key_range = info["key_list"][i]
             # expand any 2-int lists
             if isinstance(key_range, list) and len(key_range) == 2:
-                info['key_list'][i:i+1] = range(key_range[0], key_range[1]+1)
-                i += key_range[1]-key_range[0]
+                info["key_list"][i : i + 1] = range(key_range[0], key_range[1] + 1)
+                i += key_range[1] - key_range[0]
             i += 1
 
         # Expand list_names if name contains a key-based formatter
-        if '{key' in name:
-            if len(info['key_list']) == 1 and info['key_list'][0] == '*':
-                continue # will be handled later, once the key_list is known
-            for key in info['key_list']:
+        if "{key" in name:
+            if len(info["key_list"]) == 1 and info["key_list"][0] == "*":
+                continue  # will be handled later, once the key_list is known
+            for key in info["key_list"]:
                 expanded_name = name.format(key=key)
                 json_dict[expanded_name] = info.copy()
-                json_dict[expanded_name]['key_list'] = [key];
+                json_dict[expanded_name]["key_list"] = [key]
             json_dict.pop(name)
 
     # now re-iterate and expand out_paths
     for name, info in json_dict.items():
-        if len(info['key_list']) == 1 and info['key_list'][0] != "*":
-            kw_dict['key'] = info['key_list'][0]
-        if 'out_stream' in info:
-            if name != '*' and '{name' in info['out_stream']: kw_dict['name'] = name
+        if len(info["key_list"]) == 1 and info["key_list"][0] != "*":
+            kw_dict["key"] = info["key_list"][0]
+        if "out_stream" in info:
+            if name != "*" and "{name" in info["out_stream"]:
+                kw_dict["name"] = name
             try:
-                info['out_stream'] = info['out_stream'].format(**kw_dict)
+                info["out_stream"] = info["out_stream"].format(**kw_dict)
             except KeyError as msg:
-                raise KeyError(f"variable {msg} dereferenced in 'out_stream' not defined in kw_dict")
-            info['out_stream'] = os.path.expandvars(info['out_stream'])
+                raise KeyError(
+                    f"variable {msg} dereferenced in 'out_stream' not defined in kw_dict"
+                )
+            info["out_stream"] = os.path.expandvars(info["out_stream"])
 
 
-def write_to_lh5_and_clear(raw_buffers: list[RawBuffer], lh5_store: LH5Store = None,
-                           wo_mode: str = 'append') -> None:
+def write_to_lh5_and_clear(
+    raw_buffers: list[RawBuffer], lh5_store: LH5Store = None, wo_mode: str = "append"
+) -> None:
     r"""Write a list of :class:`.RawBuffer`\ s to LH5 files and then clears
     them.
 
@@ -366,20 +392,29 @@ def write_to_lh5_and_clear(raw_buffers: list[RawBuffer], lh5_store: LH5Store = N
     wo_mode : str
         write mode, see also :meth:`.lgdo.lh5_store.LH5Store.write_object`
     """
-    if lh5_store is None: lh5_store = lgdo.LH5Store()
+    if lh5_store is None:
+        lh5_store = lgdo.LH5Store()
     for rb in raw_buffers:
-        if rb.lgdo is None or rb.loc == 0: continue # no data to write
-        ii = rb.out_stream.find(':')
+        if rb.lgdo is None or rb.loc == 0:
+            continue  # no data to write
+        ii = rb.out_stream.find(":")
         if ii == -1:
             filename = rb.out_stream
-            group = '/'
+            group = "/"
         else:
             filename = rb.out_stream[:ii]
-            group = rb.out_stream[ii+1:]
-            if len(group) == 0: group = '/' # in case out_stream ends with :
+            group = rb.out_stream[ii + 1 :]
+            if len(group) == 0:
+                group = "/"  # in case out_stream ends with :
         # write if requested...
-        if filename != '':
-            lh5_store.write_object(rb.lgdo, rb.out_name, filename, group=group,
-                                   n_rows=rb.loc, wo_mode=wo_mode)
+        if filename != "":
+            lh5_store.write_object(
+                rb.lgdo,
+                rb.out_name,
+                filename,
+                group=group,
+                n_rows=rb.loc,
+                wo_mode=wo_mode,
+            )
         # and clear
         rb.loc = 0
diff --git a/src/pygama/vis/__init__.py b/src/pygama/vis/__init__.py
index 8373b5638..def121010 100644
--- a/src/pygama/vis/__init__.py
+++ b/src/pygama/vis/__init__.py
@@ -1,5 +1,7 @@
 """
-Subpackage description
+This subpackage implements utilities to visualize data.
 """
 
-from .waveform_browser import WaveformBrowser
+from pygama.vis.waveform_browser import WaveformBrowser
+
+__all__ = ["WaveformBrowser"]
diff --git a/src/pygama/vis/waveform_browser.py b/src/pygama/vis/waveform_browser.py
index 138495e72..0ed646191 100644
--- a/src/pygama/vis/waveform_browser.py
+++ b/src/pygama/vis/waveform_browser.py
@@ -1,13 +1,14 @@
-import glob
+from __future__ import annotations
+
 import itertools
 import math
-import os
 import string
 import sys
 
 import matplotlib.pyplot as plt
 import numpy as np
-import pandas as pd
+import pandas
+import pint
 from cycler import cycler
 from matplotlib.lines import Line2D
 
@@ -17,118 +18,130 @@
 
 
 class WaveformBrowser:
-    """
-    The Waveform Browser is a tool meant for interacting with waveforms from
-    LH5 files. This defines an interface for drawing waveforms from a file,
-    drawing transformed waveforms defined using build_dsp style json files,
-    drawing horizontal and vertical lines at the values of calculated
-    parameters, and filling a legend with calculated parameters.
+    """The :class:`WaveformBrowser` is a tool meant for interacting with
+    waveforms from LEGEND HDF5 files. It defines an interface for drawing
+    waveforms from a file, drawing transformed waveforms defined using
+    :func:`~.dsp.build_dsp.build_dsp` style JSON files, drawing horizontal and
+    vertical lines at the values of calculated parameters, and filling a legend
+    with calculated parameters.
     """
 
-    def __init__(self, files_in, lh5_group, base_path = '',
-                 entry_list = None, entry_mask = None,
-                 dsp_config = None, database = None,
-                 aux_values = None,
-                 lines = 'waveform', styles = None,
-                 legend = None, legend_opts = None,
-                 n_drawn = 1, x_unit = None, x_lim = None, y_lim = None,
-                 norm = None, align=None,
-                 buffer_len = 128, block_width = 8, verbosity=1):
+    def __init__(
+        self,
+        files_in: str | list[str],
+        lh5_group: str,
+        base_path: str = "",
+        entry_list: list[int] | list[list[int]] = None,
+        entry_mask: list[int] | list[list[int]] = None,
+        dsp_config: str = None,
+        database: str | dict = None,
+        aux_values: pandas.DataFrame = None,
+        lines: str | list[str] = "waveform",
+        styles: dict[str, list] | str = None,
+        legend: str | list[str] = None,
+        legend_opts: dict = None,
+        n_drawn: int = 1,
+        x_unit: pint.Unit | str = None,
+        x_lim: tuple[float | str | pint.Quantity] = None,
+        y_lim: tuple[float | str | pint.Quantity] = None,
+        norm: str = None,
+        align: str = None,
+        buffer_len: int = 128,
+        block_width: int = 8,
+    ) -> None:
         """
         Parameters
         ----------
-        files_in : str
-            name of file or list of names to browse. Can use wildcards
+        files_in
+            name of file or list of names to browse. Can use wildcards.
 
-        lh5_group : str
-            name of LH5 group in file to browse
+        lh5_group
+            name of LH5 group in file to browse.
 
-        base_path : str
-            base path for file. See LH5Store
+        base_path
+            base path for file. See :class:`~.lgdo.lh5_store.LH5Store`.
 
-        entry_list : list-like or nested list-like (optional)
-            List of event indices to draw. If it is a nested list, use local
-            indices for each file, otherwise use global indices
+        entry_list
+            list of event indices to draw. If it is a nested list, use local
+            indices for each file, otherwise use global indices.
 
-        entry_mask : array-like or list of array-likes (optional)
-            Boolean mask indicating which events to draw. If a nested list, use
+        entry_mask
+            boolean mask indicating which events to draw. If a nested list, use
             a mask for each file, else use a global mask. Cannot be used with
-            entry_list...
+            `entry_list`.
 
-        dsp_config : str (optional)
-            name of DSP config json file containing a list of processors that
-            can be applied to waveforms
+        dsp_config
+            name of DSP config JSON file containing a list of processors that
+            can be applied to waveforms.
 
-        database : str or dict-like (optional)
-            dict or JSON file with database of processing parameters
+        database
+            dictionary or JSON file with database of processing parameters.
 
-        aux_values : pandas dataframe (optional)
+        aux_values
             table of auxiliary values that are one-to-one with the input
-            waveforms that can be drawn or placed in the legend
+            waveforms that can be drawn or placed in the legend.
 
-        lines : str or [strs] (default 'waveform')
-            name(s) of objects to draw 2D lines for. Waveforms will be drawn
-            as a time-series. Scalar quantities will be drawn as horizontal
-            or vertical lines, depending on units. Vectors will be drawn
-            as multiple horizontal/vertical lines
+        lines
+            name(s) of objects to draw 2D lines for. Waveforms will be drawn as
+            a time-series. Scalar quantities will be drawn as horizontal or
+            vertical lines, depending on units. Vectors will be drawn as
+            multiple horizontal or vertical lines.
 
-        styles : (default None)
+        styles
             line colors and other style parameters to cycle through when
-            drawing waveforms. Can be given as:
+            drawing waveforms. Can be given as
 
-              - dict of lists: e.g. {'color':['r', 'g', 'b'], 'linestyle':['-', '--', '.']}
-              - name of predefined style; see matplotlib.style documentation
-              - None: use current 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.
 
-        legend : str or [strs] (default None)
-            Formatting string and values to include in the legend. This can
+        legend
+            formatting string and values to include in the legend. This can
             be a list of values (one for each drawn object). If just a name
             is given, it will be auto-formatted to 3 digits. Otherwise,
-            formatting strings in brackets can be used: ::
-
-              "{energy:0.1f} keV, {timestamp:d} ns"
-
-            Names will be searched in the input file, DSP processed parameters,
-            or auxiliary data-table
-
-        legend_opts : dict (default None)
-            dict containing additional kwargs for matplotlib.legend
+            formatting strings in brackets can be used (e.g. ``{energy:0.1f}
+            keV, {timestamp:d} ns``).  Names will be searched in the input
+            file, DSP processed parameters, or auxiliary data-table.
 
-        n_drawn : int (default 1)
-            number of events to draw simultaneously when calling DrawNext
+        legend_opts
+            dictionary containing additional keyword arguments for
+            :mod:`matplotlib.legend`.
 
-        x_lim, y_lim : tuple-pair of float, pint.Quantity or str (default auto)
-            range of x- or y-values and units passes as tuple.
+        n_drawn
+            number of events to draw simultaneously when calling
+            :meth:`draw_next`.
 
-              - None: Get range from first waveform drawn
-              - pint.Quantity: set value and x-unit
-              - float: get unit from first waveform drawn
-              - str: convert to pint.Quanity (e.g. ('0*us', '10*us'))
+        x_lim, y_lim
+            range of x- or y-values and units passed as tuple.
 
-        x_unit : pint.Unit or str (default auto)
-            unit of x-axis
+              - ``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')``).
 
-        norm : str (default None)
-            name of parameter (probably energy) to use to normalize WFs
-            useful when drawing multiple WFs
+        x_unit
+            unit of x-axis.
 
-        align : str (default None)
-            name of parameter to use for x-offset; useful, e.g., for aligning
-            multiple waveforms at a particular timepoint
+        norm
+            name of parameter (probably energy) to use to normalize waveforms.
+            Useful when drawing multiple waveforms.
 
-        buffer_len : int (default 16)
-            number of waveforms to keep in memory at a time
+        align
+            name of parameter to use for x-offset. Useful, e.g., for aligning
+            multiple waveforms at a particular timepoint.
 
-        block_width : int (default 16)
-            block width for processing chain
+        buffer_len
+            number of waveforms to keep in memory at a time.
 
-        verbosity : bool
-            print debug messages
+        block_width
+            block width for :class:`~.dsp.processing_chain.ProcessinChain`.
         """
-        self.verbosity = verbosity
 
         self.norm_par = norm
         self.align_par = align
@@ -136,39 +149,43 @@ def __init__(self, files_in, lh5_group, base_path = '',
         self.next_entry = 0
 
         # data i/o initialization
-        self.lh5_it = lh5.LH5Iterator(files_in,
-                                      lh5_group,
-                                      base_path=base_path,
-                                      entry_list=entry_list,
-                                      entry_mask=entry_mask,
-                                      buffer_len=buffer_len )
-
+        self.lh5_it = lh5.LH5Iterator(
+            files_in,
+            lh5_group,
+            base_path=base_path,
+            entry_list=entry_list,
+            entry_mask=entry_mask,
+            buffer_len=buffer_len,
+        )
 
         # Get the input buffer and read the first chunk
         self.lh5_in, _ = self.lh5_it.read(0)
 
         self.aux_vals = aux_values
         # Apply entry selection to aux_vals if needed
-        if self.aux_vals is not None and len(self.aux_vals)>len(self.lh5_it):
+        if self.aux_vals is not None and len(self.aux_vals) > len(self.lh5_it):
             entries = []
             for i, f_entries in enumerate(self.lh5_it.entry_list):
-                entry_offset = self.lh5_it.file_map[i-1] if i>0 else 0
-                entries += [ entry_offset + entry for entry in f_entries ]
+                entry_offset = self.lh5_it.file_map[i - 1] if i > 0 else 0
+                entries += [entry_offset + entry for entry in f_entries]
             self.aux_vals = self.aux_vals.iloc[entries].reset_index()
 
         # initialize objects to draw: dict from name to list of 2DLines
-        if isinstance(lines, str): self.lines = { lines:[] }
-        elif lines is None: self.lines = {}
-        else: self.lines = { l:[] for l in lines }
+        if isinstance(lines, str):
+            self.lines = {lines: []}
+        elif lines is None:
+            self.lines = {}
+        else:
+            self.lines = {line: [] for line in lines}
 
         # styles
         if isinstance(styles, (list, tuple)):
-            self.styles = [ None for _ in self.lines ]
+            self.styles = [None for _ in self.lines]
             for i, sty in enumerate(styles):
                 if isinstance(sty, str):
                     try:
-                        self.styles[i] = plt.style.library[sty]['axes.prop_cycle']
-                    except:
+                        self.styles[i] = plt.style.library[sty]["axes.prop_cycle"]
+                    except KeyError:
                         self.styles[i] = itertools.repeat(None)
                 elif sty is None:
                     self.styles[i] = itertools.repeat(None)
@@ -177,20 +194,21 @@ def __init__(self, files_in, lh5_group, base_path = '',
         else:
             if isinstance(styles, str):
                 try:
-                    self.styles = plt.style.library[styles]['axes.prop_cycle']
-                except:
+                    self.styles = plt.style.library[styles]["axes.prop_cycle"]
+                except KeyError:
                     self.styles = itertools.repeat(None)
             elif styles is None:
                 self.styles = itertools.repeat(None)
             else:
                 self.styles = cycler(**styles)
 
-
-        self.legend_format = [] # list of formatter strings
+        self.legend_format = []  # list of formatter strings
         self.legend_vals = {}  # Set up dict from names to lists of values
 
-        if legend is None: legend = []
-        elif isinstance(legend, str): legend = [legend]
+        if legend is None:
+            legend = []
+        elif isinstance(legend, str):
+            legend = [legend]
 
         for entry in legend:
             legend_format = ""
@@ -200,13 +218,13 @@ def __init__(self, files_in, lh5_group, base_path = '',
                     legend_format += st
                     break
 
-                if name=='':
-                    raise KeyError("Cannot use empty formatter in "+entry)
+                if name == "":
+                    raise KeyError("Cannot use empty formatter in " + entry)
                 self.legend_vals[name] = []
 
-                if form is None or form=='':
-                    form = '~0.3P'
-                cv = '' if cv is None or cv=='' else "!"+cv
+                if form is None or form == "":
+                    form = "~0.3P"
+                cv = "" if cv is None or cv == "" else "!" + cv
                 legend_format += f"{st}{{{name}:{form}{cv}}}"
             self.legend_format.append(legend_format)
 
@@ -214,21 +232,32 @@ def __init__(self, files_in, lh5_group, base_path = '',
 
         # make processing chain and output buffer
         outputs = list(self.lines) + list(self.legend_vals)
-        if isinstance(self.norm_par, str): outputs += [self.norm_par]
-        if isinstance(self.align_par, str): outputs += [self.align_par]
-
+        if isinstance(self.norm_par, str):
+            outputs += [self.norm_par]
+        if isinstance(self.align_par, str):
+            outputs += [self.align_par]
 
         # Remove any values not found in aux_vals
         if self.aux_vals is not None:
-            outputs = [ o for o in outputs if o not in self.aux_vals ]
-
-        self.proc_chain, self.lh5_it.field_mask, self.lh5_out = build_processing_chain(self.lh5_in, dsp_config, db_dict=database, outputs=outputs, verbosity=self.verbosity, block_width=block_width)
+            outputs = [o for o in outputs if o not in self.aux_vals]
+
+        self.proc_chain, self.lh5_it.field_mask, self.lh5_out = build_processing_chain(
+            self.lh5_in,
+            dsp_config,
+            db_dict=database,
+            outputs=outputs,
+            block_width=block_width,
+        )
         self.proc_chain.execute()
 
         # Check if all of our outputs can be found
         for name in outputs:
-            if not name in self.lh5_out:
-                raise KeyError("Could not find "+name+" in input lh5 file, DSP config file, or aux values")
+            if name not in self.lh5_out:
+                raise KeyError(
+                    "Could not find "
+                    + name
+                    + " in input lh5 file, DSP config file, or aux values"
+                )
 
         self.x_unit = ureg(x_unit) if x_unit else None
         self.x_lim = x_lim
@@ -244,7 +273,7 @@ def __init__(self, files_in, lh5_group, base_path = '',
                     self.x_lim[i] = ureg.Quantity(x_lim[i])
                 if isinstance(self.x_lim[i], ureg.Quantity):
                     if self.x_unit:
-                        self.x_lim[i] = float(self.x_lim[i]/self.x_unit)
+                        self.x_lim[i] = float(self.x_lim[i] / self.x_unit)
                     else:
                         self.x_unit = self.x_lim[i].u
                         self.x_lim[i] = self.x_lim[i].m
@@ -252,25 +281,30 @@ def __init__(self, files_in, lh5_group, base_path = '',
         # If we still have no x_unit get it from the first waveform we can find
         if self.x_unit is None:
             for wf in self.lh5_out.values():
-                if not isinstance(wf, lh5.WaveformTable): continue
+                if not isinstance(wf, lh5.WaveformTable):
+                    continue
                 self.x_unit = ureg(wf.dt_units)
 
         self.fig = None
         self.ax = None
 
-    def new_figure(self):
-        """Create a new figure and draw in it"""
+    def new_figure(self) -> None:
+        """Create a new figure and draw in it."""
         self.fig, self.ax = plt.subplots(1)
 
-    def save_figure(self, f_out, *args, **kwargs):
-        """ Write figure to file named f_out. See matplotlib.pyplot.savefig
-        for args and kwargs"""
+    def save_figure(self, f_out: str, *args, **kwargs) -> None:
+        """Write figure to file named `f_out`. See
+        :func:`matplotlib.pyplot.savefig` for `args` and `kwargs`."""
         self.fig.savefig(f_out)
 
-    def set_figure(self, fig, ax=None):
-        """Use an already existing figure and axis; make sure to set clear
-        to False when drawing if you don't want to clear what's already there!
-        Can give a WaveformBrowser object to use the fig/axis from that"""
+    def set_figure(
+        self, fig: WaveformBrowser | plt.Figure, ax: plt.Axes = None
+    ) -> None:
+        """Use an already existing figure and axis.
+
+        Make sure to set ``clear=False`` when drawing if you don't want to
+        clear what's already there! Can give a :class:`WaveformBrowser` object
+        to use the figure / axis from that."""
         if isinstance(fig, WaveformBrowser):
             self.fig = fig.fig
             self.ax = fig.ax
@@ -285,47 +319,54 @@ def set_figure(self, fig, ax=None):
         else:
             raise TypeError("fig must be matplotlib.Figure or WaveformBrowser")
 
-    def clear_data(self):
-        """ Reset the currently stored data """
-        for line_data in self.lines.values(): line_data.clear()
-        for leg_data in self.legend_vals.values(): leg_data.clear()
+    def clear_data(self) -> None:
+        """Reset the currently stored data."""
+        for line_data in self.lines.values():
+            line_data.clear()
+        for leg_data in self.legend_vals.values():
+            leg_data.clear()
         self.auto_x_lim = [np.inf, -np.inf]
         self.auto_y_lim = [np.inf, -np.inf]
         self.n_stored = 0
 
-    def find_entry(self, entry, append=True, safe=False):
-        """
-        Find the requested data associated with entry in input files and
+    def find_entry(
+        self, entry: int | list[int], append: bool = True, safe: bool = False
+    ) -> None:
+        """Find the requested data associated with entry in input files and
         place store it internally without drawing it.
 
         Parameters
         ----------
-        entry : int or [ints]
-            index of entry or list of entries to find
-        append : bool (default True)
-            if False, clear previously found data before finding more
-        safe : bool (default False)
-            if False, throw an exception for out of range entries
+        entry
+            index of entry or list of entries to find.
+        append
+            if ``False``, clear previously found data before finding more.
+        safe
+            if ``False``, throw an exception for out of range entries.
         """
-        if not append: self.clear_data()
-        if hasattr(entry, '__iter__'):
-            for idx in entry: self.find_entry(idx)
+        if not append:
+            self.clear_data()
+        if hasattr(entry, "__iter__"):
+            for idx in entry:
+                self.find_entry(idx)
             return
 
         if entry > len(self.lh5_it):
-            if safe: raise IndexError
-            else: return
+            if safe:
+                raise IndexError
+            else:
+                return
 
         # Get our current position in the I/O buffers; update if needed
         i_tb = entry - self.lh5_it.current_entry
-        if not ( self.lh5_it.n_rows > i_tb >= 0 ):
+        if not (self.lh5_it.n_rows > i_tb >= 0):
             self.lh5_it.read(entry)
             self.proc_chain.execute()
             i_tb = 0
 
         # get scaling factor/time shift if used
         if self.norm_par is None:
-            norm = 1.
+            norm = 1.0
         elif isinstance(self.norm_par, str):
             norm = self.lh5_out[self.norm_par].nda[entry]
         else:
@@ -337,50 +378,60 @@ def find_entry(self, entry, append=True, safe=False):
             data = self.lh5_out.get(self.align_par, None)
             if isinstance(data, lh5.Array):
                 ref_time = data.nda[i_tb]
-                unit = data.attrs.get('units', None)
+                unit = data.attrs.get("units", None)
                 if unit and unit in ureg and ureg.is_compatible_with(unit, self.x_unit):
-                    ref_time *= float(ureg(unit)/self.x_unit)
+                    ref_time *= float(ureg(unit) / self.x_unit)
             elif data is None:
                 ref_time = self.aux_vals[self.align_par][entry]
             else:
                 raise
 
-        leg_handle = None
-
         # lines
-        lim = math.sqrt(sys.float_info.max) # limits for v/h lines
+        lim = math.sqrt(sys.float_info.max)  # limits for v/h lines
         for name, lines in self.lines.items():
             # Get the data; note this is implicitly copying it!
             data = self.lh5_out.get(name, None)
             if isinstance(data, lh5.WaveformTable):
-                y = data.values.nda[i_tb,:]/norm - ref_time
-                dt = data.dt.nda[i_tb] * float(ureg(data.dt_units)/self.x_unit)
-                t0 = data.t0.nda[i_tb] * float(ureg(data.t0_units)/self.x_unit)
-                x = np.linspace(t0, t0+dt*(data.wf_len-1), data.wf_len)
+                y = data.values.nda[i_tb, :] / norm
+                dt = data.dt.nda[i_tb] * float(ureg(data.dt_units) / self.x_unit)
+                t0 = (
+                    data.t0.nda[i_tb] * float(ureg(data.t0_units) / self.x_unit)
+                    - ref_time
+                )
+                x = np.linspace(t0, t0 + dt * (data.wf_len - 1), data.wf_len)
+                lines.append(Line2D(x, y))
+                self._update_auto_limit(x, y)
+
+            elif isinstance(data, lh5.ArrayOfEqualSizedArrays):
+                y = data.nda[i_tb, :] / norm
+                x = np.arange(len(y), dtype="float")
                 lines.append(Line2D(x, y))
                 self._update_auto_limit(x, y)
 
             elif isinstance(data, lh5.Array):
                 val = data.nda[i_tb]
-                unit = data.attrs.get('units', None)
+                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 = val * float(ureg(unit) / self.x_unit) - ref_time
+                    lines.append(Line2D([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], [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 = self.aux_vals[name][entry] / norm
+                lines.append(Line2D([-lim, lim], [val] * 2))
                 self._update_auto_limit(None, val)
 
             else:
-                raise TypeError("Cannot draw "+name+". WaveformBrowser does not support drawing lines for data of type " + str(data.__class__))
+                raise TypeError(
+                    f"Cannot draw '{name}'. WaveformBrowser does not support "
+                    f"drawing lines for data of type {data.__class__}"
+                )
 
         # legend data
         for name, vals in self.legend_vals.items():
@@ -389,23 +440,24 @@ def find_entry(self, entry, append=True, safe=False):
             if not data:
                 data = ureg.Quantity(self.aux_vals[name][entry])
             elif isinstance(data, lh5.Array):
-                unit = data.attrs.get('units', None)
+                unit = data.attrs.get("units", None)
                 if unit and unit in ureg:
-                    data = data.nda[i_tb]*ureg(unit)
+                    data = data.nda[i_tb] * ureg(unit)
                 else:
                     data = ureg.Quantity(data.nda[i_tb])
             else:
-                raise TypeError("WaveformBrowser does not adding legend entries for data of type " + data.__class__)
+                raise TypeError(
+                    "WaveformBrowser does not adding legend entries for data "
+                    f"of type {data.__class__}"
+                )
 
             vals.append(data)
 
         self.n_stored += 1
         self.next_entry = entry + 1
 
-    def draw_current(self, clear=True):
-        """
-        Draw the waveforms and data currently held internally by this class.
-        """
+    def draw_current(self, clear: bool = True) -> None:
+        """Draw the waveforms and data currently held internally by this class."""
         # Make figure/axis if needed
         if not (self.ax and self.fig and plt.fignum_exists(self.fig.number)):
             self.new_figure()
@@ -417,7 +469,10 @@ def draw_current(self, clear=True):
         y_lim = self.y_lim
         if not y_lim:
             y_range = self.auto_y_lim[1] - self.auto_y_lim[0]
-            y_lim = [self.auto_y_lim[0] - 0.05*y_range, self.auto_y_lim[1] + 0.05*y_range]
+            y_lim = [
+                self.auto_y_lim[0] - 0.05 * y_range,
+                self.auto_y_lim[1] + 0.05 * y_range,
+            ]
         self.ax.set_xlim(*x_lim)
         self.ax.set_ylim(*y_lim)
 
@@ -434,8 +489,10 @@ def draw_current(self, clear=True):
                 styles = cycler(plt.rcparams)
 
             for line, sty in zip(lines, styles):
-                if sty is not None: line.update(sty)
-                if line.get_figure() is not None: line.remove()
+                if sty is not None:
+                    line.update(sty)
+                if line.get_figure() is not None:
+                    line.remove()
                 line.set_transform(self.ax.transData)
                 self.ax.add_line(line)
 
@@ -446,7 +503,7 @@ def draw_current(self, clear=True):
             leg_cycle = cycler(**self.legend_vals)
         except Exception:
             for form in self.legend_format:
-                for i in range(self.n_stored):
+                for _ in range(self.n_stored):
                     leg_labels.append(form)
         else:
             for form in self.legend_format:
@@ -458,22 +515,24 @@ def draw_current(self, clear=True):
         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:
+        if len(leg_labels) > 0:
             if not clear:
                 old_leg = self.ax.get_legend()
                 if old_leg:
                     leg_handles = old_leg.get_lines() + leg_handles
-                    leg_labels = [t.get_text() for t in old_leg.get_texts()] + leg_labels
+                    leg_labels = [
+                        t.get_text() for t in old_leg.get_texts()
+                    ] + leg_labels
             self.ax.legend(leg_handles, leg_labels, **self.legend_kwargs)
 
-    def _update_auto_limit(self, x, y):
+    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]))
+            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]))
+            x_where["where"] = (x >= self.x_lim[0]) & (x <= self.x_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])
@@ -481,42 +540,52 @@ def _update_auto_limit(self, x, y):
             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])
 
-    def draw_entry(self, entry, append=False, clear=True, safe=False):
-        """
-        Draw specified entry in the current figure/axes
+    def draw_entry(
+        self,
+        entry: int | list[int],
+        append: bool = False,
+        clear: bool = True,
+        safe: bool = False,
+    ) -> None:
+        """Draw specified entry in the current figure/axes.
 
         Parameters
         ----------
-        entry : int or [ints]
-            entry or list of entries to draw
-        append : bool (default False)
-            if True, do not clear previously drawn entries before drawing more
-        clear : bool (default True)
-            if True, clear previously drawn objects in the axes before drawing
-        safe : bool (default False)
-            if False, throw an exception for out of range entries
+        entry
+            entry or list of entries to draw.
+        append
+            if ``True``, do not clear previously drawn entries before drawing more.
+        clear
+            if ``True``, clear previously drawn objects in the axes before drawing.
+        safe
+            if ``False``, throw an exception for out of range entries.
         """
         self.find_entry(entry, append)
         self.draw_current(clear)
 
-    def find_next(self, n_wfs = None, append = False):
-        """Find the next n_wfs waveforms (default self.n_drawn). See find_entry"""
-        if not n_wfs: n_wfs = self.n_drawn
-        entries = (self.next_entry, self.next_entry+n_wfs)
+    def find_next(self, n_wfs: int = None, append: bool = False) -> tuple[int, int]:
+        """Find the next `n_wfs` waveforms (default `self.n_drawn`). See
+        :meth:`find_entry`."""
+        if not n_wfs:
+            n_wfs = self.n_drawn
+        entries = (self.next_entry, self.next_entry + n_wfs)
         self.find_entry(range(*entries), append, safe=True)
         return entries
 
-    def draw_next(self, n_wfs = None, append = False, clear = True):
-        """Draw the next n_wfs waveforms (default self.n_drawn). See draw_next"""
+    def draw_next(
+        self, n_wfs: int = None, append: bool = False, clear: bool = True
+    ) -> tuple[int, int]:
+        """Draw the next `n_wfs` waveforms (default `self.n_drawn`). See
+        :meth:`draw_next`."""
         entries = self.find_next(append)
         self.draw_current(clear)
         return entries
 
-    def reset(self):
-        """ Reset to the start of the file for draw_next """
+    def reset(self) -> None:
+        """Reset to the start of the file for :meth:`draw_next`."""
         self.clear_data()
         self.next_entry = 0
 
-    def __iter__(self):
+    def __iter__(self) -> tuple[int, int]:
         while self.next_entry < len(self.lh5_it):
             yield self.draw_next()
diff --git a/tutorials/metadata/LPGTA_dsp.json b/tutorials/metadata/LPGTA_dsp.json
index 9db719341..e243f3531 100644
--- a/tutorials/metadata/LPGTA_dsp.json
+++ b/tutorials/metadata/LPGTA_dsp.json
@@ -1,15 +1,28 @@
 {
-  "outputs": [ "trapE", "trapEftp", "ct_corr", "bl", "bl_sig", "A_10", "AoE",
-             "tp_max", "tp_0", "tp_10", "tp_50", "tp_80", "tp_90",
-             "dcr_raw"],
-  "processors":{
-  "bl , bl_sig, slope, intercept":{
+  "outputs": [
+    "trapE",
+    "trapEftp",
+    "ct_corr",
+    "bl",
+    "bl_sig",
+    "A_10",
+    "AoE",
+    "tp_max",
+    "tp_0",
+    "tp_10",
+    "tp_50",
+    "tp_80",
+    "tp_90",
+    "dcr_raw"
+  ],
+  "processors": {
+    "bl , bl_sig, slope, intercept": {
       "function": "linear_slope_fit",
       "module": "pygama.dsp.processors",
-      "args" : ["waveform[:1650]", "bl","bl_sig", "slope","intercept"],
-      "unit": ["ADC","ADC","ADC","ADC"]
+      "args": ["waveform[:1650]", "bl", "bl_sig", "slope", "intercept"],
+      "unit": ["ADC", "ADC", "ADC", "ADC"]
     },
-    "wf_blsub":{
+    "wf_blsub": {
       "function": "subtract",
       "module": "numpy",
       "args": ["waveform", "bl", "wf_blsub"],
@@ -22,7 +35,7 @@
       "args": ["wf_blsub", "db.pz_const", "wf_pz"],
       "prereqs": ["wf_blsub"],
       "unit": "ADC",
-      "defaults": { "db.pz_const":"400*us" }
+      "defaults": { "db.pz_const": "400*us" }
     },
     "wf_trap": {
       "function": "trap_norm",
@@ -35,15 +48,25 @@
       "function": "asym_trap_filter",
       "module": "pygama.dsp.processors",
       "prereqs": ["wf_pz"],
-      "args": ["wf_pz", "db.atrap.rise", "db.atrap.flat", "db.atrap.fall", "wf_atrap"],
-      "defaults" : {"db.atrap.rise":"round(20*ns)", "db.atrap.flat":"round(1*us)","db.atrap.fall":"round(4*us)"},
+      "args": [
+        "wf_pz",
+        "db.atrap.rise",
+        "db.atrap.flat",
+        "db.atrap.fall",
+        "wf_atrap"
+      ],
+      "defaults": {
+        "db.atrap.rise": "round(20*ns)",
+        "db.atrap.flat": "round(1*us)",
+        "db.atrap.fall": "round(4*us)"
+      },
       "unit": "ADC"
     },
     "trapE": {
       "function": "amax",
       "module": "numpy",
       "args": ["wf_trap", 1, "trapE"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
       "unit": "ADC",
       "prereqs": ["wf_trap"]
     },
@@ -51,7 +74,7 @@
       "function": "fixed_time_pickoff",
       "module": "pygama.dsp.processors",
       "args": ["wf_trap", "tp_0+13.5*us", "trapEftp"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
       "unit": "ADC",
       "prereqs": ["wf_trap", "tp_0"]
     },
@@ -67,14 +90,14 @@
       "function": "amax",
       "module": "numpy",
       "args": ["curr10", 1, "A_10"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
       "unit": "ADC",
       "prereqs": ["curr10"]
     },
     "AoE": {
       "function": "divide",
       "module": "numpy",
-      "args":["A_10", "trapE", "AoE"],
+      "args": ["A_10", "trapE", "AoE"],
       "unit": "1/sample",
       "prereqs": ["A_10", "trapE"]
     },
@@ -82,7 +105,7 @@
       "function": "argmax",
       "module": "numpy",
       "args": ["wf_blsub", 1, "tp_max"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->i"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->i"] },
       "unit": "ns",
       "prereqs": ["wf_blsub"]
     },
@@ -130,7 +153,7 @@
     "ct_corr": {
       "function": "trap_pickoff",
       "module": "pygama.dsp.processors",
-      "args":["wf_pz", "4*us", 0, "tp_0", "ct_corr"],
+      "args": ["wf_pz", "4*us", 0, "tp_0", "ct_corr"],
       "unit": "ADC",
       "prereqs": ["wf_pz", "tp_0"]
     }
diff --git a/tutorials/metadata/cage.json b/tutorials/metadata/cage.json
index 8dfdd2258..50e8304ab 100644
--- a/tutorials/metadata/cage.json
+++ b/tutorials/metadata/cage.json
@@ -1,25 +1,25 @@
 {
-  "experiment" : "cage",
+  "experiment": "cage",
 
-  "runDB" : "/global/project/projectdirs/legend/software/CAGE/processing/runDB.json",
-  "fileDB" : "/global/project/projectdirs/legend/software/CAGE/processing/fileDB.h5",
-  "runSelectionDB" : "/global/project/projectdirs/legend/software/CAGE/processing/metadata/run_selection.json",
+  "runDB": "/global/project/projectdirs/legend/software/CAGE/processing/runDB.json",
+  "fileDB": "/global/project/projectdirs/legend/software/CAGE/processing/fileDB.h5",
+  "runSelectionDB": "/global/project/projectdirs/legend/software/CAGE/processing/metadata/run_selection.json",
 
-  "daq" : "ORCA",
-  "daq_dir" : "/global/cfs/cdirs/m2676/data/cage/Data",
-  "daq_ignore" : [".log",".DS_Store", "openFiles"],
-  "lh5_dir" : "/global/cfs/cdirs/m2676/data/cage/LH5",
+  "daq": "ORCA",
+  "daq_dir": "/global/cfs/cdirs/m2676/data/cage/Data",
+  "daq_ignore": [".log", ".DS_Store", "openFiles"],
+  "lh5_dir": "/global/cfs/cdirs/m2676/data/cage/LH5",
 
-  "nersc_dir":"/global/project/projectdirs/m2676/data/cage",
+  "nersc_dir": "/global/project/projectdirs/m2676/data/cage",
 
-  "tier_dirs" : ["raw","dsp","hit"],
-  "subsystems" : [""],
-  "run_types" : ["",""],
-  "evt_dirs" : [""],
+  "tier_dirs": ["raw", "dsp", "hit"],
+  "subsystems": [""],
+  "run_types": ["", ""],
+  "evt_dirs": [""],
 
-  "unique_key" : "cage-cyc{cycle}-{YYYY}{mm}{dd}",
-  "daq_template" : "{YYYY}-{mm}-{dd}-CAGERun{cycle}",
-  "lh5_template" : "cage_run{run}_cyc{cycle}_{tier}.lh5",
+  "unique_key": "cage-cyc{cycle}-{YYYY}{mm}{dd}",
+  "daq_template": "{YYYY}-{mm}-{dd}-CAGERun{cycle}",
+  "lh5_template": "cage_run{run}_cyc{cycle}_{tier}.lh5",
 
-  "ecal_default" : "/global/project/projectdirs/legend/software/CAGE/processing/metadata/config_ecal.json"
+  "ecal_default": "/global/project/projectdirs/legend/software/CAGE/processing/metadata/config_ecal.json"
 }
diff --git a/tutorials/metadata/dsp_config.json b/tutorials/metadata/dsp_config.json
index 509dab4a2..b2201c668 100644
--- a/tutorials/metadata/dsp_config.json
+++ b/tutorials/metadata/dsp_config.json
@@ -1,15 +1,32 @@
 {
   "outputs": [
-    "timestamp", "channel", "trapEmax", "trapEftp", "triE", "bl_mean", "bl_sig", "A_10", "AoE", "dcr", "zacE", "cuspE"
+    "timestamp",
+    "channel",
+    "trapEmax",
+    "trapEftp",
+    "triE",
+    "bl_mean",
+    "bl_sig",
+    "A_10",
+    "AoE",
+    "dcr",
+    "zacE",
+    "cuspE"
   ],
-  "processors":{
-    "bl_mean , bl_sig, bl_slope, bl_intercept":{
+  "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"]
+      "args": [
+        "waveform[0: 1000]",
+        "bl_mean",
+        "bl_sig",
+        "bl_slope",
+        "bl_intercept"
+      ],
+      "unit": ["ADC", "ADC", "ADC", "ADC"]
     },
-    "wf_blsub":{
+    "wf_blsub": {
       "function": "subtract",
       "module": "numpy",
       "args": ["waveform", "bl_mean", "wf_blsub"],
@@ -20,7 +37,7 @@
       "module": "pygama.dsp.processors",
       "args": ["wf_blsub", "db.pz_const", "wf_pz"],
       "unit": "ADC",
-      "defaults": { "db.pz_const":"72*us" }
+      "defaults": { "db.pz_const": "72*us" }
     },
     "wf_trap": {
       "function": "trap_norm",
@@ -40,78 +57,78 @@
       "args": ["wf_pz", "0.05*us", "2*us", "3*us", "wf_atrap"],
       "unit": "ADC"
     },
-    "tp_min, tp_max, wf_min, wf_max":{
+    "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"]
+      "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"
-     },
+    },
     "tp_95": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
-      "args": ["wf_blsub", "0.95*tp_max", "tp_max", 0,"tp_95"],
+      "args": ["wf_blsub", "0.95*tp_max", "tp_max", 0, "tp_95"],
       "unit": "ns"
-     },
+    },
     "tp_80": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
-      "args": ["wf_blsub", "0.8*tp_max", "tp_max", 0,"tp_80"],
+      "args": ["wf_blsub", "0.8*tp_max", "tp_max", 0, "tp_80"],
       "unit": "ns"
-     },
+    },
     "tp_50": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
-      "args": ["wf_blsub", "0.50*tp_max", "tp_max", 0,"tp_50"],
+      "args": ["wf_blsub", "0.50*tp_max", "tp_max", 0, "tp_50"],
       "unit": "ns"
-     },
+    },
     "tp_20": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
-      "args": ["wf_blsub", "0.2*tp_max", "tp_max", 0,"tp_20"],
+      "args": ["wf_blsub", "0.2*tp_max", "tp_max", 0, "tp_20"],
       "unit": "ns"
-     },
+    },
     "tp_05": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
-      "args": ["wf_blsub", "0.05*tp_max", "tp_max", 0,"tp_05"],
+      "args": ["wf_blsub", "0.05*tp_max", "tp_max", 0, "tp_05"],
       "unit": "ns"
-     },
-     "trapEftp": {
-       "function": "fixed_time_pickoff",
-       "module": "pygama.dsp.processors",
-       "args": ["wf_trap", "tp_0+(5*us+9*us)", "trapEftp"],
-       "unit": "ADC"
-     },
-     "dcr_raw": {
-       "function": "trap_pickoff",
-       "module": "pygama.dsp.processors",
-       "args": ["wf_pz", 200, 1000, "tp_0+1.504*us", "dcr_raw"],
-       "unit": "ADC"
-     },
-     "dcr": {
-       "function": "divide",
-       "module": "numpy",
-       "args": ["dcr_raw", "trapEftp", "dcr"],
-       "unit": "ADC"
-     },
+    },
+    "trapEftp": {
+      "function": "fixed_time_pickoff",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_trap", "tp_0+(5*us+9*us)", "trapEftp"],
+      "unit": "ADC"
+    },
+    "dcr_raw": {
+      "function": "trap_pickoff",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", 200, 1000, "tp_0+1.504*us", "dcr_raw"],
+      "unit": "ADC"
+    },
+    "dcr": {
+      "function": "divide",
+      "module": "numpy",
+      "args": ["dcr_raw", "trapEftp", "dcr"],
+      "unit": "ADC"
+    },
     "trapEmax": {
       "function": "amax",
       "module": "numpy",
       "args": ["wf_trap", 1, "trapEmax"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
       "unit": "ADC"
     },
     "triE": {
       "function": "amax",
       "module": "numpy",
       "args": ["wf_triangle", 1, "triE"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
       "unit": "ADC"
     },
     "curr10": {
@@ -125,41 +142,51 @@
       "function": "amax",
       "module": "numpy",
       "args": ["curr10", 1, "A_10"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
       "unit": "ADC"
     },
     "AoE": {
       "function": "divide",
       "module": "numpy",
-      "args":["A_10", "trapEmax", "AoE"],
+      "args": ["A_10", "trapEmax", "AoE"],
       "unit": "1/sample"
     },
     "zac_wf": {
       "function": "zac_filter",
       "module": "pygama.dsp.processors",
       "args": ["waveform", "zac_wf(101, 'f')"],
-      "init_args": ["len(waveform)", "round(3.5*us/waveform.period)", "round(2.5*us/waveform.period)", "round(72*us/waveform.period)"],
+      "init_args": [
+        "len(waveform)",
+        "round(3.5*us/waveform.period)",
+        "round(2.5*us/waveform.period)",
+        "round(72*us/waveform.period)"
+      ],
       "unit": "ADC"
     },
     "zacE": {
       "function": "amax",
       "module": "numpy",
       "args": ["zac_wf", 1, "zacE"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
       "unit": "ADC"
     },
     "cusp_wf": {
       "function": "cusp_filter",
       "module": "pygama.dsp.processors",
       "args": ["waveform", "cusp_wf(101, 'f')"],
-      "init_args": ["len(waveform)", "round(10*us/waveform.period)", "round(1*us/waveform.period)", "round(200*us/waveform.period)"],
+      "init_args": [
+        "len(waveform)",
+        "round(10*us/waveform.period)",
+        "round(1*us/waveform.period)",
+        "round(200*us/waveform.period)"
+      ],
       "unit": "ADC"
     },
     "cuspE": {
       "function": "amax",
       "module": "numpy",
       "args": ["cusp_wf", 1, "cuspE"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
       "unit": "ADC"
     },
     "curr": {
@@ -179,7 +206,7 @@
       "function": "amax",
       "module": "numpy",
       "args": ["curr_psd[10*mhz*len(curr):]", 1, "hf_max"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
       "unit": ""
     },
     "power_spect": {
@@ -189,6 +216,5 @@
       "init_args": ["wf_blsub", "power_spect(len(wf_blsub)//2+1, 'f')"],
       "unit": "MHz"
     }
-
   }
 }
diff --git a/tutorials/metadata/optimizer_results.json b/tutorials/metadata/optimizer_results.json
index fdcbc39d1..dca6f86a2 100644
--- a/tutorials/metadata/optimizer_results.json
+++ b/tutorials/metadata/optimizer_results.json
@@ -1,1 +1,9 @@
-{"pz2": {"tau1": "52.00*us", "tau2": "6.40*us", "frac": "0.043"}, "etrap": {"rise": "11.00*us", "flat": "3.00*us"}, "atrap": {"rise": "20*ns", "flat": "1*us", "fall": "4*us"}, "tp_ftp": {"ftp": "tp_0 + 12.5*us"}, "dcr_pz": {"tau1": "52.00*us", "tau2": "6.40*us", "frac": "0.043"}, "dcr_trap": {"flat": "20.33*us", "rise": "7.00*us"}, "dcr": {"ftp": "80*us"}}
+{
+  "pz2": { "tau1": "52.00*us", "tau2": "6.40*us", "frac": "0.043" },
+  "etrap": { "rise": "11.00*us", "flat": "3.00*us" },
+  "atrap": { "rise": "20*ns", "flat": "1*us", "fall": "4*us" },
+  "tp_ftp": { "ftp": "tp_0 + 12.5*us" },
+  "dcr_pz": { "tau1": "52.00*us", "tau2": "6.40*us", "frac": "0.043" },
+  "dcr_trap": { "flat": "20.33*us", "rise": "7.00*us" },
+  "dcr": { "ftp": "80*us" }
+}


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 892b422d2..ab6684291 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -5,5 +5,5 @@
 @pytest.fixture(scope="session")
 def lgnd_test_data():
     ldata = LegendTestData()
-    ldata.checkout('968c9ba')
+    ldata.checkout("968c9ba")
     return ldata
diff --git a/tests/dsp/configs/icpc-dsp-config.json b/tests/dsp/configs/icpc-dsp-config.json
new file mode 100644
index 000000000..0168da525
--- /dev/null
+++ b/tests/dsp/configs/icpc-dsp-config.json
@@ -0,0 +1,327 @@
+{
+  "outputs": [
+    "tp_min",
+    "tp_max",
+    "wf_min",
+    "wf_max",
+    "bl_mean",
+    "bl_std",
+    "bl_slope",
+    "bl_intercept",
+    "pz_slope",
+    "pz_std",
+    "pz_mean",
+    "trapTmax",
+    "tp_0_est",
+    "tp_0_atrap",
+    "tp_10",
+    "tp_20",
+    "tp_50",
+    "tp_80",
+    "tp_90",
+    "tp_99",
+    "tp_100",
+    "tp_01",
+    "tp_95",
+    "A_max",
+    "QDrift",
+    "dt_eff",
+    "tp_aoe_max",
+    "tp_aoe_samp",
+    "trapEmax",
+    "trapEftp",
+    "cuspEmax",
+    "zacEmax",
+    "zacEftp",
+    "cuspEftp"
+  ],
+  "processors": {
+    "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"]
+    },
+    "wf_blsub": {
+      "function": "bl_subtract",
+      "module": "pygama.dsp.processors",
+      "args": ["waveform", "baseline", "wf_blsub"],
+      "unit": "ADC"
+    },
+    "bl_mean , bl_std, bl_slope, bl_intercept": {
+      "function": "linear_slope_fit",
+      "module": "pygama.dsp.processors",
+      "args": [
+        "wf_blsub[0:750]",
+        "bl_mean",
+        "bl_std",
+        "bl_slope",
+        "bl_intercept"
+      ],
+      "unit": ["ADC", "ADC", "ADC", "ADC"]
+    },
+    "wf_pz": {
+      "function": "pole_zero",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", "db.pz.tau", "wf_pz"],
+      "unit": "ADC",
+      "defaults": { "db.pz.tau": "27460.5" }
+    },
+    "pz_mean , pz_std, pz_slope, pz_intercept": {
+      "function": "linear_slope_fit",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz[1500:]", "pz_mean", "pz_std", "pz_slope", "pz_intercept"],
+      "unit": ["ADC", "ADC", "ADC", "ADC"]
+    },
+    "wf_t0_filter": {
+      "function": "t0_filter",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "wf_t0_filter(len(wf_pz), 'f', grid=wf_pz.grid)"],
+      "init_args": ["128*ns/wf_pz.period", "2*us/wf_pz.period"],
+      "unit": "ADC"
+    },
+    "wf_atrap": {
+      "function": "asym_trap_filter",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "128*ns", "4", "2*us", "wf_atrap"],
+      "unit": "ADC"
+    },
+    "conv_tmin ,tp_start, conv_min, conv_max": {
+      "function": "min_max",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_t0_filter", "conv_tmin", "tp_start", "conv_min", "conv_max"],
+      "unit": ["ns", "ns", "ADC", "ADC"]
+    },
+    "tp_0_atrap": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_atrap", "bl_std", "tp_start", 0, "tp_0_atrap"],
+      "unit": "ns"
+    },
+    "tp_0_est": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_t0_filter", "bl_std", "tp_start", 0, "tp_0_est(unit=ns)"],
+      "unit": "ns"
+    },
+    "wf_trap": {
+      "function": "trap_norm",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "db.ttrap.rise", "db.ttrap.flat", "wf_trap"],
+      "unit": "ADC",
+      "defaults": { "db.ttrap.rise": "10*us", "db.ttrap.flat": "3.008*us" }
+    },
+    "trapTmax": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["wf_trap", 1, "trapTmax"],
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
+      "unit": "ADC"
+    },
+    "wf_etrap": {
+      "function": "trap_norm",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "db.etrap.rise", "db.etrap.flat", "wf_etrap"],
+      "unit": "ADC",
+      "defaults": { "db.etrap.rise": "10*us", "db.etrap.flat": "3.008*us" }
+    },
+    "trapEmax": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["wf_etrap", 1, "trapEmax"],
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
+      "unit": "ADC"
+    },
+    "trapEftp": {
+      "function": "fixed_time_pickoff",
+      "module": "pygama.dsp.processors",
+      "args": [
+        "wf_etrap",
+        "tp_0_est+db.etrap.rise+db.etrap.flat*db.etrap.sample",
+        "trapEftp"
+      ],
+      "unit": "ADC",
+      "defaults": {
+        "db.etrap.rise": "10*us",
+        "db.etrap.flat": "3*us",
+        "db.etrap.sample": "0.8"
+      }
+    },
+    "wf_cusp": {
+      "function": "cusp_filter",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", "wf_cusp(101, 'f')"],
+      "init_args": [
+        "len(wf_blsub)-100",
+        "db.cusp.sigma/wf_blsub.period",
+        "db.cusp.flat/wf_blsub.period",
+        "db.pz.tau"
+      ],
+      "defaults": {
+        "db.cusp.sigma": "20*us",
+        "db.cusp.flat": "3*us",
+        "db.pz.tau": "27460.5"
+      },
+      "unit": "ADC"
+    },
+    "cuspEmax": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["wf_cusp", 1, "cuspEmax"],
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
+      "unit": "ADC"
+    },
+    "cuspEftp": {
+      "function": "fixed_time_pickoff",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_cusp", "db.cusp.sample", "cuspEftp"],
+      "unit": "ADC",
+      "defaults": { "db.cusp.sample": "50" }
+    },
+    "wf_zac": {
+      "function": "zac_filter",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", "wf_zac(101, 'f')"],
+      "init_args": [
+        "len(wf_blsub)-100",
+        "db.zac.sigma/wf_blsub.period",
+        "db.zac.flat/wf_blsub.period",
+        "db.pz.tau"
+      ],
+      "defaults": {
+        "db.zac.sigma": "20*us",
+        "db.zac.flat": "3*us",
+        "db.pz.tau": "27460.5"
+      },
+      "unit": "ADC"
+    },
+    "zacEmax": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["wf_zac", 1, "zacEmax"],
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
+      "unit": "ADC"
+    },
+    "zacEftp": {
+      "function": "fixed_time_pickoff",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_zac", "db.zac.sample", "zacEftp"],
+      "defaults": { "db.zac.sample": "50" },
+      "unit": "ADC"
+    },
+    "tp_100": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "trapTmax", "tp_0_est", 1, "tp_100"],
+      "unit": "ns"
+    },
+    "tp_99": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "0.99*trapTmax", "tp_0_est", 1, "tp_99"],
+      "unit": "ns"
+    },
+    "tp_95": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "trapTmax*0.95", "tp_99", 0, "tp_95"],
+      "unit": "ns"
+    },
+    "tp_90": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "trapTmax*0.9", "tp_95", 0, "tp_90"],
+      "unit": "ns"
+    },
+    "tp_80": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "trapTmax*0.8", "tp_90", 0, "tp_80"],
+      "unit": "ns"
+    },
+    "tp_50": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "trapTmax*0.5", "tp_80", 0, "tp_50"],
+      "unit": "ns"
+    },
+    "tp_20": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "trapTmax*0.2", "tp_50", 0, "tp_20"],
+      "unit": "ns"
+    },
+    "tp_10": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "trapTmax*0.1", "tp_20", 0, "tp_10"],
+      "unit": "ns"
+    },
+    "tp_01": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "trapTmax*0.01", "tp_10", 0, "tp_01"],
+      "unit": "ns"
+    },
+    "wf_trap2": {
+      "function": "trap_norm",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "4*us", "96*ns", "wf_trap2"],
+      "unit": "ADC"
+    },
+    "trapQftp": {
+      "function": "fixed_time_pickoff",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_trap2", "tp_0_est + 8.096*us", "trapQftp"],
+      "unit": "ADC"
+    },
+    "QDrift": {
+      "function": "multiply",
+      "module": "numpy",
+      "args": ["trapQftp", "(4*us)/waveform.period", "QDrift"],
+      "unit": "ADC"
+    },
+    "dt_eff": {
+      "function": "divide",
+      "module": "numpy",
+      "args": ["QDrift", "trapTmax", "dt_eff"],
+      "unit": "ns"
+    },
+    "wf_le": {
+      "function": "windower",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "tp_0_est", "wf_le(301, 'f')"],
+      "unit": "ADC"
+    },
+    "curr": {
+      "function": "avg_current",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_le", 1, "curr(len(wf_le)-1, 'f')"],
+      "unit": "ADC/sample"
+    },
+    "curr_up": {
+      "function": "upsampler",
+      "module": "pygama.dsp.processors",
+      "args": ["curr", "16", "curr_up(4784, 'f')"],
+      "unit": "ADC/sample"
+    },
+    "curr_av": {
+      "function": "moving_window_multi",
+      "module": "pygama.dsp.processors",
+      "args": ["curr_up", "48", 3, 0, "curr_av"],
+      "unit": "ADC/sample"
+    },
+    "aoe_t_min, tp_aoe_max, A_min, A_max": {
+      "function": "min_max",
+      "module": "pygama.dsp.processors",
+      "args": ["curr_av", "aoe_t_min", "tp_aoe_max", "A_min", "A_max"],
+      "unit": ["ns", "ns", "ADC/sample", "ADC/sample"]
+    },
+    "tp_aoe_samp": {
+      "function": "add",
+      "module": "numpy",
+      "args": ["tp_0_est", "tp_aoe_max/16", "tp_aoe_samp"],
+      "unit": "ns"
+    }
+  }
+}
diff --git a/tests/dsp/configs/sipm-dsp-config.json b/tests/dsp/configs/sipm-dsp-config.json
new file mode 100644
index 000000000..3b015024c
--- /dev/null
+++ b/tests/dsp/configs/sipm-dsp-config.json
@@ -0,0 +1,23 @@
+{
+  "outputs": ["bl_mean", "bl_std"],
+  "processors": {
+    "wf_blsub": {
+      "function": "bl_subtract",
+      "module": "pygama.dsp.processors",
+      "args": ["waveform", "baseline", "wf_blsub"],
+      "unit": "ADC"
+    },
+    "bl_mean , bl_std, bl_slope, bl_intercept": {
+      "function": "linear_slope_fit",
+      "module": "pygama.dsp.processors",
+      "args": [
+        "wf_blsub[0:750]",
+        "bl_mean",
+        "bl_std",
+        "bl_slope",
+        "bl_intercept"
+      ],
+      "unit": ["ADC", "ADC", "ADC", "ADC"]
+    }
+  }
+}
diff --git a/tests/dsp/conftest.py b/tests/dsp/conftest.py
new file mode 100644
index 000000000..b5da016d9
--- /dev/null
+++ b/tests/dsp/conftest.py
@@ -0,0 +1,38 @@
+import pytest
+
+import pygama.dsp.processors  # noqa: F401
+from pygama.lgdo import LH5Store
+from pygama.raw.build_raw import build_raw
+
+
+@pytest.fixture(scope="session")
+def geds_raw_tbl(lgnd_test_data):
+    store = LH5Store()
+    obj, _ = store.read_object(
+        "/geds/raw",
+        lgnd_test_data.get_path("lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5"),
+        n_rows=10,
+    )
+    return obj
+
+
+@pytest.fixture(scope="session")
+def spms_raw_tbl(lgnd_test_data):
+
+    out_file = "/tmp/L200-comm-20211130-phy-spms.lh5"
+    out_spec = {
+        "FCEventDecoder": {
+            "raw": {"key_list": [[0, 6]], "out_stream": f"{out_file}:/spms"}
+        }
+    }
+
+    build_raw(
+        in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
+        out_spec=out_spec,
+        n_max=10,
+        overwrite=True,
+    )
+
+    store = LH5Store()
+    obj, _ = store.read_object("/spms/raw", out_file, n_rows=10)
+    return obj
diff --git a/tests/dsp/test_build_dsp.py b/tests/dsp/test_build_dsp.py
new file mode 100644
index 000000000..c1685cc65
--- /dev/null
+++ b/tests/dsp/test_build_dsp.py
@@ -0,0 +1,88 @@
+import os
+from pathlib import Path
+
+import pytest
+
+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")
+
+    with pytest.raises(FileExistsError):
+        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",
+        )
+
+    with pytest.raises(FileNotFoundError):
+        build_dsp(
+            "non-existent-file.lh5",
+            "/tmp/LDQTA_r117_20200110T105115Z_cal_geds_dsp.lh5",
+            dsp_config=f"{config_dir}/icpc-dsp-config.json",
+            write_mode="r",
+        )
+
+
+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",
+    )
+
+    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"]
+
+    store = LH5Store()
+    lh5_obj, n_rows = store.read_object("/ch0/dsp/bl_mean", out_file)
+    assert isinstance(lh5_obj, lgdo.Array)
+    assert len(lh5_obj) == 5
diff --git a/tests/dsp/test_processing_chain.py b/tests/dsp/test_processing_chain.py
new file mode 100644
index 000000000..bef13e5e8
--- /dev/null
+++ b/tests/dsp/test_processing_chain.py
@@ -0,0 +1,132 @@
+import pytest
+
+from pygama import lgdo
+from pygama.dsp.processing_chain import build_processing_chain
+
+
+def test_waveform_slicing(geds_raw_tbl):
+    dsp_config = {
+        "outputs": ["wf_blsub"],
+        "processors": {
+            "wf_blsub": {
+                "function": "bl_subtract",
+                "module": "pygama.dsp.processors",
+                "args": ["waveform[0:100]", "baseline", "wf_blsub"],
+                "unit": "ADC",
+            },
+        },
+    }
+    proc_chain, _, tbl_out = build_processing_chain(geds_raw_tbl, dsp_config)
+    proc_chain.execute(0, 1)
+
+    assert list(tbl_out.keys()) == ["wf_blsub"]
+    assert isinstance(tbl_out["wf_blsub"], lgdo.WaveformTable)
+    assert tbl_out["wf_blsub"].wf_len == 100
+
+
+def test_processor_none_arg(geds_raw_tbl):
+    dsp_config = {
+        "outputs": ["wf_cum"],
+        "processors": {
+            "wf_cum": {
+                "function": "cumsum",
+                "module": "numpy",
+                "args": ["waveform", 1, None, "wf_cum"],
+                "kwargs": {"signature": "(n),(),()->(n)", "types": ["fii->f"]},
+                "unit": "ADC",
+            }
+        },
+    }
+    proc_chain, _, _ = build_processing_chain(geds_raw_tbl, dsp_config)
+    proc_chain.execute(0, 1)
+
+    dsp_config["processors"]["wf_cum"]["args"][2] = "None"
+    proc_chain, _, _ = build_processing_chain(geds_raw_tbl, dsp_config)
+    proc_chain.execute(0, 1)
+
+
+def test_processor_kwarg_assignment(geds_raw_tbl):
+    dsp_config = {
+        "outputs": ["wf_cum"],
+        "processors": {
+            "wf_cum": {
+                "function": "cumsum",
+                "module": "numpy",
+                "args": ["waveform", "axis=1", "out=wf_cum"],
+                "kwargs": {"signature": "(n),()->(n)", "types": ["fi->f"]},
+                "unit": "ADC",
+            }
+        },
+    }
+    proc_chain, _, _ = build_processing_chain(geds_raw_tbl, dsp_config)
+    proc_chain.execute(0, 1)
+
+    dsp_config["processors"]["wf_cum"]["args"][1] = "dtypo=None"
+    proc_chain, _, _ = build_processing_chain(geds_raw_tbl, dsp_config)
+    with pytest.raises(TypeError):
+        proc_chain.execute(0, 1)
+
+
+def test_processor_dtype_arg(geds_raw_tbl):
+    dsp_config = {
+        "outputs": ["wf_cum"],
+        "processors": {
+            "wf_cum": {
+                "function": "cumsum",
+                "module": "numpy",
+                "args": ["waveform", "axis=0", "dtype='int32'", "out=wf_cum"],
+                "kwargs": {"signature": "(n),(),()->(n)", "types": ["fiU->i"]},
+                "unit": "ADC",
+            }
+        },
+    }
+    proc_chain, _, _ = build_processing_chain(geds_raw_tbl, dsp_config)
+    proc_chain.execute(0, 1)
+
+
+def test_scipy_gauss_filter(geds_raw_tbl):
+    dsp_config = {
+        "outputs": ["wf_gaus"],
+        "processors": {
+            "wf_gaus": {
+                "function": "gaussian_filter1d",
+                "module": "scipy.ndimage",
+                "args": [
+                    "waveform",
+                    "0.1*us",
+                    "mode='reflect'",
+                    "truncate=3",
+                    "output=wf_gaus",
+                ],
+                "kwargs": {"signature": "(n),(),(),()->(n)", "types": ["ffUf->f"]},
+                "unit": "ADC",
+            }
+        },
+    }
+    proc_chain, _, _ = build_processing_chain(geds_raw_tbl, dsp_config)
+    proc_chain.execute(0, 1)
+
+
+def test_processor_variable_array_output(spms_raw_tbl):
+    dsp_config = {
+        "outputs": ["wf_cum"],
+        "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",
+                    "vt_max_out(10)",
+                    "vt_min_out(10)",
+                    "n_max_out",
+                    "n_min_out",
+                    "flag_out",
+                ],
+                "unit": "ADC",
+            }
+        },
+    }
+
+    proc_chain, _, _ = build_processing_chain(spms_raw_tbl, dsp_config)
+    proc_chain.execute(0, 1)
diff --git a/tests/lgdo/test_array.py b/tests/lgdo/test_array.py
index a197ea301..00cf603a4 100644
--- a/tests/lgdo/test_array.py
+++ b/tests/lgdo/test_array.py
@@ -5,19 +5,19 @@
 
 def test_datatype_name():
     array = lgdo.Array()
-    assert array.datatype_name() == 'array'
+    assert array.datatype_name() == "array"
 
 
 def test_form_datatype():
     array = lgdo.Array(shape=(12, 34))
-    assert array.form_datatype() == 'array<2>{real}'
+    assert array.form_datatype() == "array<2>{real}"
 
 
 def test_init():
-    attrs = {'attr1': 1}
+    attrs = {"attr1": 1}
     array = lgdo.Array(shape=(3,), dtype=np.float32, fill_val=42, attrs=attrs)
     assert (array.nda == np.full((3,), 42, np.float32)).all()
-    assert array.attrs == attrs | {'datatype': 'array<1>{real}'}
+    assert array.attrs == attrs | {"datatype": "array<1>{real}"}
 
 
 def test_resize():
diff --git a/tests/lgdo/test_arrayofequalsizedarrays.py b/tests/lgdo/test_arrayofequalsizedarrays.py
index 23a9044b5..d5c23cbaa 100644
--- a/tests/lgdo/test_arrayofequalsizedarrays.py
+++ b/tests/lgdo/test_arrayofequalsizedarrays.py
@@ -5,17 +5,19 @@
 
 def test_datatype_name():
     aoesa = lgdo.ArrayOfEqualSizedArrays()
-    assert aoesa.datatype_name() == 'array_of_equalsized_arrays'
+    assert aoesa.datatype_name() == "array_of_equalsized_arrays"
 
 
 def test_form_datatype():
     aoesa = lgdo.ArrayOfEqualSizedArrays(dims=(2, 3))
-    assert aoesa.form_datatype() == 'array_of_equalsized_arrays<2,3>{real}'
+    assert aoesa.form_datatype() == "array_of_equalsized_arrays<2,3>{real}"
 
 
 def test_init():
-    attrs = {'attr1': 1}
-    aoesa = lgdo.ArrayOfEqualSizedArrays(dims=(2, 3), dtype=np.float32, fill_val=42, attrs=attrs)
+    attrs = {"attr1": 1}
+    aoesa = lgdo.ArrayOfEqualSizedArrays(
+        dims=(2, 3), dtype=np.float32, fill_val=42, attrs=attrs
+    )
     assert aoesa.dims == (2, 3)
     assert (aoesa.nda == np.full((2, 3), 42, np.float32)).all()
-    assert aoesa.attrs == attrs | {'datatype': 'array_of_equalsized_arrays<2,3>{real}'}
+    assert aoesa.attrs == attrs | {"datatype": "array_of_equalsized_arrays<2,3>{real}"}
diff --git a/tests/lgdo/test_fixedsizearray.py b/tests/lgdo/test_fixedsizearray.py
index 44be048a2..ce0c6b18e 100644
--- a/tests/lgdo/test_fixedsizearray.py
+++ b/tests/lgdo/test_fixedsizearray.py
@@ -5,16 +5,16 @@
 
 def test_datatype_name():
     array = lgdo.FixedSizeArray()
-    assert array.datatype_name() == 'fixedsize_array'
+    assert array.datatype_name() == "fixedsize_array"
 
 
 def test_form_datatype():
     array = lgdo.FixedSizeArray()
-    assert array.form_datatype() == 'fixedsize_array<0>{real}'
+    assert array.form_datatype() == "fixedsize_array<0>{real}"
 
 
 def test_init():
-    attrs = {'attr1': 1}
+    attrs = {"attr1": 1}
     array = lgdo.FixedSizeArray(shape=(3,), dtype=np.float32, fill_val=42, attrs=attrs)
-    assert (array.nda == np.full((3, ), 42, np.float32)).all()
-    assert array.attrs == attrs | {'datatype': 'fixedsize_array<1>{real}'}
+    assert (array.nda == np.full((3,), 42, np.float32)).all()
+    assert array.attrs == attrs | {"datatype": "fixedsize_array<1>{real}"}
diff --git a/tests/lgdo/test_lgdo_utils.py b/tests/lgdo/test_lgdo_utils.py
index 5e21e7d3d..cdc7cdc07 100644
--- a/tests/lgdo/test_lgdo_utils.py
+++ b/tests/lgdo/test_lgdo_utils.py
@@ -6,15 +6,15 @@
 def test_get_element_type():
 
     objs = [
-        ('hi', 'string'),
-        (True, 'bool'),
-        (np.void(0), 'blob'),
-        (int(0), 'real'),
-        (np.uint8(0), 'real'),
-        (float(0), 'real'),
-        (1+1j, 'complex'),
-        (b'hi', 'string'),
-        (np.array(['hi']), 'string'),
+        ("hi", "string"),
+        (True, "bool"),
+        (np.void(0), "blob"),
+        (int(0), "real"),
+        (np.uint8(0), "real"),
+        (float(0), "real"),
+        (1 + 1j, "complex"),
+        (b"hi", "string"),
+        (np.array(["hi"]), "string"),
     ]
 
     for obj, name in objs:
@@ -25,13 +25,19 @@ def test_get_element_type():
 def test_parse_datatype():
 
     datatypes = [
-        ('real', ('scalar', None, 'real')),
-        ('array<1>{bool}', ('array', (1,), 'bool')),
-        ('fixedsizearray<2>{real}', ('fixedsizearray', (2,), 'real')),
-        ('arrayofequalsizedarrays<3,4>{complex}', ('arrayofequalsizedarrays', (3, 4), 'complex')),
-        ('array<1>{array<1>{blob}}', ('array', (1,), 'array<1>{blob}')),
-        ('struct{field1,field2,fieldn}', ('struct', None, ['field1', 'field2', 'fieldn'])),
-        ('table{col1,col2,coln}', ('table', None, ['col1', 'col2', 'coln'])),
+        ("real", ("scalar", None, "real")),
+        ("array<1>{bool}", ("array", (1,), "bool")),
+        ("fixedsizearray<2>{real}", ("fixedsizearray", (2,), "real")),
+        (
+            "arrayofequalsizedarrays<3,4>{complex}",
+            ("arrayofequalsizedarrays", (3, 4), "complex"),
+        ),
+        ("array<1>{array<1>{blob}}", ("array", (1,), "array<1>{blob}")),
+        (
+            "struct{field1,field2,fieldn}",
+            ("struct", None, ["field1", "field2", "fieldn"]),
+        ),
+        ("table{col1,col2,coln}", ("table", None, ["col1", "col2", "coln"])),
     ]
 
     for string, dt_tuple in datatypes:
diff --git a/tests/lgdo/test_lh5_store.py b/tests/lgdo/test_lh5_store.py
index 0f37d8787..5542fe500 100644
--- a/tests/lgdo/test_lh5_store.py
+++ b/tests/lgdo/test_lh5_store.py
@@ -8,9 +8,9 @@
 from pygama.lgdo.lh5_store import LH5Iterator, LH5Store
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def lgnd_file(lgnd_test_data):
-    return lgnd_test_data.get_path('lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5')
+    return lgnd_test_data.get_path("lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5")
 
 
 def test_init():
@@ -25,87 +25,103 @@ def test_gimme_file(lgnd_file):
     assert store.files[lgnd_file] == f
 
     with pytest.raises(FileNotFoundError):
-        store.gimme_file('non-existent-file')
+        store.gimme_file("non-existent-file")
 
 
 def test_gimme_group(lgnd_file):
     f = h5py.File(lgnd_file)
     store = LH5Store()
-    g = store.gimme_group('/geds', f)
+    g = store.gimme_group("/geds", f)
     assert isinstance(g, h5py.Group)
 
-    f = h5py.File('/tmp/testfile.lh5', mode='w')
-    g = store.gimme_group('/geds', f, grp_attrs={'attr1': 1}, overwrite=True)
+    f = h5py.File("/tmp/testfile.lh5", mode="w")
+    g = store.gimme_group("/geds", f, grp_attrs={"attr1": 1}, overwrite=True)
     assert isinstance(g, h5py.Group)
 
 
 def test_ls(lgnd_file):
-    assert lh5.ls(lgnd_file) == ['geds']
-    assert lh5.ls(lgnd_file, '/*/raw') == ['geds/raw']
-    assert lh5.ls(lgnd_file, 'geds/raw/') == [
-        'geds/raw/baseline',
-        'geds/raw/channel',
-        'geds/raw/energy',
-        'geds/raw/ievt',
-        'geds/raw/numtraces',
-        'geds/raw/packet_id',
-        'geds/raw/timestamp',
-        'geds/raw/tracelist',
-        'geds/raw/waveform',
-        'geds/raw/wf_max',
-        'geds/raw/wf_std'
+    assert lh5.ls(lgnd_file) == ["geds"]
+    assert lh5.ls(lgnd_file, "/*/raw") == ["geds/raw"]
+    assert lh5.ls(lgnd_file, "geds/raw/") == [
+        "geds/raw/baseline",
+        "geds/raw/channel",
+        "geds/raw/energy",
+        "geds/raw/ievt",
+        "geds/raw/numtraces",
+        "geds/raw/packet_id",
+        "geds/raw/timestamp",
+        "geds/raw/tracelist",
+        "geds/raw/waveform",
+        "geds/raw/wf_max",
+        "geds/raw/wf_std",
     ]
 
 
 def test_load_nda(lgnd_file):
-    nda = lh5.load_nda([lgnd_file, lgnd_file],
-                       ['baseline', 'waveform/values'],
-                       lh5_group='/geds/raw',
-                       idx_list=[[1, 3, 5], [2, 6, 7]])
+    nda = lh5.load_nda(
+        [lgnd_file, lgnd_file],
+        ["baseline", "waveform/values"],
+        lh5_group="/geds/raw",
+        idx_list=[[1, 3, 5], [2, 6, 7]],
+    )
 
     assert isinstance(nda, dict)
-    assert isinstance(nda['baseline'], np.ndarray)
-    assert nda['baseline'].shape == (6,)
-    assert isinstance(nda['waveform/values'], np.ndarray)
-    assert nda['waveform/values'].shape == (6, 5592)
+    assert isinstance(nda["baseline"], np.ndarray)
+    assert nda["baseline"].shape == (6,)
+    assert isinstance(nda["waveform/values"], np.ndarray)
+    assert nda["waveform/values"].shape == (6, 5592)
 
 
 def test_load_dfs(lgnd_file):
-    dfs = lh5.load_dfs([lgnd_file, lgnd_file],
-                       ['baseline', 'waveform/t0'],
-                       lh5_group='/geds/raw',
-                       idx_list=[[1, 3, 5], [2, 6, 7]])
+    dfs = lh5.load_dfs(
+        [lgnd_file, lgnd_file],
+        ["baseline", "waveform/t0"],
+        lh5_group="/geds/raw",
+        idx_list=[[1, 3, 5], [2, 6, 7]],
+    )
 
     assert isinstance(dfs, pd.DataFrame)
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def lh5_file():
     store = LH5Store()
 
     struct = lgdo.Struct()
-    struct.add_field('scalar', lgdo.Scalar(value=10))
-    struct.add_field('array', lgdo.Array(nda=np.array([1, 2, 3, 4, 5])))
-    struct.add_field('aoesa', lgdo.ArrayOfEqualSizedArrays(shape=(5, 5), dtype=np.float32, fill_val=42))
-    struct.add_field('vov', lgdo.VectorOfVectors(
-        flattened_data=lgdo.Array(nda=np.array([1, 2, 3, 4, 5, 2, 4, 8, 9, 7, 5, 3, 1])),
-        cumulative_length=lgdo.Array(nda=np.array([2, 5, 6, 10, 13]))
-    ))
+    struct.add_field("scalar", lgdo.Scalar(value=10))
+    struct.add_field("array", lgdo.Array(nda=np.array([1, 2, 3, 4, 5])))
+    struct.add_field(
+        "aoesa",
+        lgdo.ArrayOfEqualSizedArrays(shape=(5, 5), dtype=np.float32, fill_val=42),
+    )
+    struct.add_field(
+        "vov",
+        lgdo.VectorOfVectors(
+            flattened_data=lgdo.Array(
+                nda=np.array([1, 2, 3, 4, 5, 2, 4, 8, 9, 7, 5, 3, 1])
+            ),
+            cumulative_length=lgdo.Array(nda=np.array([2, 5, 6, 10, 13])),
+        ),
+    )
 
     col_dict = {
-        'a': lgdo.Array(nda=np.array([1, 2, 3, 4])),
-        'b': lgdo.Array(nda=np.array([5, 6, 7, 8]))
+        "a": lgdo.Array(nda=np.array([1, 2, 3, 4])),
+        "b": lgdo.Array(nda=np.array([5, 6, 7, 8])),
     }
 
-    struct.add_field('table', lgdo.Table(col_dict=col_dict))
+    struct.add_field("table", lgdo.Table(col_dict=col_dict))
 
-    store.write_object(struct, 'struct',
-                       '/tmp/tmp-pygama-lgdo-types.lh5',
-                       group='/data',
-                       start_row=1, n_rows=3,
-                       wo_mode='overwrite_file')
+    store.write_object(
+        struct,
+        "struct",
+        "/tmp/tmp-pygama-lgdo-types.lh5",
+        group="/data",
+        start_row=1,
+        n_rows=3,
+        wo_mode="overwrite_file",
+    )
 
-    return '/tmp/tmp-pygama-lgdo-types.lh5'
+    return "/tmp/tmp-pygama-lgdo-types.lh5"
 
 
 def test_write_objects(lh5_file):
@@ -114,7 +130,7 @@ def test_write_objects(lh5_file):
 
 def test_read_scalar(lh5_file):
     store = LH5Store()
-    lh5_obj, n_rows = store.read_object('/data/struct/scalar', lh5_file)
+    lh5_obj, n_rows = store.read_object("/data/struct/scalar", lh5_file)
     assert isinstance(lh5_obj, lgdo.Scalar)
     assert lh5_obj.value == 10
     assert n_rows == 1
@@ -122,7 +138,7 @@ def test_read_scalar(lh5_file):
 
 def test_read_array(lh5_file):
     store = LH5Store()
-    lh5_obj, n_rows = store.read_object('/data/struct/array', lh5_file)
+    lh5_obj, n_rows = store.read_object("/data/struct/array", lh5_file)
     assert isinstance(lh5_obj, lgdo.Array)
     assert (lh5_obj.nda == np.array([2, 3, 4])).all()
     assert n_rows == 3
@@ -130,12 +146,10 @@ def test_read_array(lh5_file):
 
 def test_read_vov(lh5_file):
     store = LH5Store()
-    lh5_obj, n_rows = store.read_object('/data/struct/vov', lh5_file)
+    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, 2]), np.array([]), np.array([])]
 
     for i in range(len(desired)):
         assert (desired[i] == list(lh5_obj)[i]).all()
@@ -145,36 +159,36 @@ def test_read_vov(lh5_file):
 
 def test_read_aoesa(lh5_file):
     store = LH5Store()
-    lh5_obj, n_rows = store.read_object('/data/struct/aoesa', lh5_file)
+    lh5_obj, n_rows = store.read_object("/data/struct/aoesa", lh5_file)
     assert isinstance(lh5_obj, lgdo.ArrayOfEqualSizedArrays)
     assert (lh5_obj.nda == np.full((3, 5), fill_value=42)).all()
 
 
 def test_read_table(lh5_file):
     store = LH5Store()
-    lh5_obj, n_rows = store.read_object('/data/struct/table', lh5_file)
+    lh5_obj, n_rows = store.read_object("/data/struct/table", lh5_file)
     assert isinstance(lh5_obj, lgdo.Table)
     assert n_rows == 3
 
-    lh5_obj, n_rows = store.read_object('/data/struct/table', [lh5_file, lh5_file])
+    lh5_obj, n_rows = store.read_object("/data/struct/table", [lh5_file, lh5_file])
     assert n_rows == 6
 
 
 def test_read_lgnd_array(lgnd_file):
     store = LH5Store()
 
-    lh5_obj, n_rows = store.read_object('/geds/raw/baseline', lgnd_file)
+    lh5_obj, n_rows = store.read_object("/geds/raw/baseline", lgnd_file)
     assert isinstance(lh5_obj, lgdo.Array)
     assert n_rows == 100
     assert len(lh5_obj) == 100
 
-    lh5_obj, n_rows = store.read_object('/geds/raw/waveform/values', lgnd_file)
+    lh5_obj, n_rows = store.read_object("/geds/raw/waveform/values", lgnd_file)
     assert isinstance(lh5_obj, lgdo.ArrayOfEqualSizedArrays)
 
 
 def test_read_array_concatenation(lgnd_file):
     store = LH5Store()
-    lh5_obj, n_rows = store.read_object('/geds/raw/baseline', [lgnd_file, lgnd_file])
+    lh5_obj, n_rows = store.read_object("/geds/raw/baseline", [lgnd_file, lgnd_file])
     assert isinstance(lh5_obj, lgdo.Array)
     assert n_rows == 200
     assert len(lh5_obj) == 200
@@ -183,29 +197,38 @@ def test_read_array_concatenation(lgnd_file):
 def test_read_lgnd_waveform_table(lgnd_file):
     store = LH5Store()
 
-    lh5_obj, n_rows = store.read_object('/geds/raw/waveform', lgnd_file)
+    lh5_obj, n_rows = store.read_object("/geds/raw/waveform", lgnd_file)
     assert isinstance(lh5_obj, lgdo.WaveformTable)
 
-    lh5_obj, n_rows = store.read_object('/geds/raw/waveform', lgnd_file,
-                                        start_row=10, n_rows=10,
-                                        field_mask=['t0', 'dt'])
+    lh5_obj, n_rows = store.read_object(
+        "/geds/raw/waveform",
+        lgnd_file,
+        start_row=10,
+        n_rows=10,
+        field_mask=["t0", "dt"],
+    )
 
     assert isinstance(lh5_obj, lgdo.Table)
-    assert list(lh5_obj.keys()) == ['t0', 'dt']
+    assert list(lh5_obj.keys()) == ["t0", "dt"]
     assert len(lh5_obj) == 10
 
 
 def test_lh5_iterator(lgnd_file):
-    lh5_it = LH5Iterator(lgnd_file, '/geds/raw',
-                         entry_list=range(100),
-                         field_mask=['baseline'],
-                         buffer_len=5)
+    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()
+    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
diff --git a/tests/lgdo/test_scalar.py b/tests/lgdo/test_scalar.py
index f28dcaa68..3ff4c5183 100644
--- a/tests/lgdo/test_scalar.py
+++ b/tests/lgdo/test_scalar.py
@@ -3,17 +3,17 @@
 
 def test_datatype_name():
     scalar = lgdo.Scalar(value=42)
-    assert scalar.datatype_name() == 'real'
+    assert scalar.datatype_name() == "real"
 
 
 def test_form_datatype():
     scalar = lgdo.Scalar(value=42)
-    assert scalar.form_datatype() == 'real'
+    assert scalar.form_datatype() == "real"
 
 
 # TODO: check for warning if mismatched datatype
 def test_init():
-    attrs = {'attr1': 1}
+    attrs = {"attr1": 1}
     scalar = lgdo.Scalar(value=42, attrs=attrs)
     assert scalar.value == 42
-    assert scalar.attrs == attrs | {'datatype': 'real'}
+    assert scalar.attrs == attrs | {"datatype": "real"}
diff --git a/tests/lgdo/test_struct.py b/tests/lgdo/test_struct.py
index 61e970f5f..e7f1c85ec 100644
--- a/tests/lgdo/test_struct.py
+++ b/tests/lgdo/test_struct.py
@@ -3,34 +3,34 @@
 
 def test_datatype_name():
     struct = lgdo.Struct()
-    assert struct.datatype_name() == 'struct'
+    assert struct.datatype_name() == "struct"
 
 
 def test_form_datatype():
     struct = lgdo.Struct()
-    assert struct.form_datatype() == 'struct{}'
+    assert struct.form_datatype() == "struct{}"
 
 
 def test_update_datatype():
     struct = lgdo.Struct()
     struct.update_datatype()
-    assert struct.attrs['datatype'] == 'struct{}'
+    assert struct.attrs["datatype"] == "struct{}"
 
 
 def test_init():
-    obj_dict = {'scalar1': lgdo.Scalar(value=10)}
-    attrs = {'attr1': 1}
+    obj_dict = {"scalar1": lgdo.Scalar(value=10)}
+    attrs = {"attr1": 1}
     struct = lgdo.Struct(obj_dict=obj_dict, attrs=attrs)
     assert dict(struct) == obj_dict
-    assert struct.attrs == attrs | {'datatype': 'struct{scalar1}'}
+    assert struct.attrs == attrs | {"datatype": "struct{scalar1}"}
 
 
 def test_add_field():
     struct = lgdo.Struct()
-    struct.add_field('scalar1', lgdo.Scalar(value=10))
+    struct.add_field("scalar1", lgdo.Scalar(value=10))
 
-    assert struct.attrs['datatype'] == 'struct{scalar1}'
-    assert struct['scalar1'].__class__.__name__ == 'Scalar'
+    assert struct.attrs["datatype"] == "struct{scalar1}"
+    assert struct["scalar1"].__class__.__name__ == "Scalar"
 
-    struct.add_field('array1', lgdo.Array(shape=(700, 21), dtype='f', fill_val=2))
-    assert struct.attrs['datatype'] == 'struct{scalar1,array1}'
+    struct.add_field("array1", lgdo.Array(shape=(700, 21), dtype="f", fill_val=2))
+    assert struct.attrs["datatype"] == "struct{scalar1,array1}"
diff --git a/tests/lgdo/test_table.py b/tests/lgdo/test_table.py
index b2b44c88f..4323b19b3 100644
--- a/tests/lgdo/test_table.py
+++ b/tests/lgdo/test_table.py
@@ -15,8 +15,8 @@ def test_init():
     assert tbl.size == 10
 
     col_dict = {
-        'a': lgdo.Array(nda=np.array([1, 2, 3, 4])),
-        'b': lgdo.Array(nda=np.array([5, 6, 7, 8]))
+        "a": lgdo.Array(nda=np.array([1, 2, 3, 4])),
+        "b": lgdo.Array(nda=np.array([5, 6, 7, 8])),
     }
 
     tbl = Table(col_dict=col_dict)
@@ -28,7 +28,7 @@ def test_init():
 
 def test_datatype_name():
     tbl = Table()
-    assert tbl.datatype_name() == 'table'
+    assert tbl.datatype_name() == "table"
 
 
 def test_push_row():
@@ -54,39 +54,39 @@ def test_clear():
 
 def test_add_field():
     tbl = Table()
-    tbl.add_field('a', lgdo.Array(np.array([1, 2, 3])), use_obj_size=True)
+    tbl.add_field("a", lgdo.Array(np.array([1, 2, 3])), use_obj_size=True)
     assert tbl.size == 3
 
     with pytest.raises(TypeError):
-        tbl.add_field('s', lgdo.Scalar(value=69))
+        tbl.add_field("s", lgdo.Scalar(value=69))
 
 
 def test_add_column():
     tbl = Table()
-    tbl.add_column('a', lgdo.Array(np.array([1, 2, 3])), use_obj_size=True)
+    tbl.add_column("a", lgdo.Array(np.array([1, 2, 3])), use_obj_size=True)
     assert tbl.size == 3
 
 
 def test_join():
     tbl1 = Table(size=3)
-    tbl1.add_field('a', lgdo.FixedSizeArray(np.array([1, 2, 3])))
-    tbl1.add_field('b', lgdo.Array(np.array([1, 2, 3])))
-    assert list(tbl1.keys()) == ['a', 'b']
+    tbl1.add_field("a", lgdo.FixedSizeArray(np.array([1, 2, 3])))
+    tbl1.add_field("b", lgdo.Array(np.array([1, 2, 3])))
+    assert list(tbl1.keys()) == ["a", "b"]
 
     tbl2 = Table(size=3)
-    tbl2.add_field('c', lgdo.Array(np.array([4, 5, 6])))
-    tbl2.add_field('d', lgdo.Array(np.array([9, 9, 10])))
+    tbl2.add_field("c", lgdo.Array(np.array([4, 5, 6])))
+    tbl2.add_field("d", lgdo.Array(np.array([9, 9, 10])))
 
     tbl1.join(tbl2)
-    assert list(tbl1.keys()) == ['a', 'b', 'c', 'd']
+    assert list(tbl1.keys()) == ["a", "b", "c", "d"]
 
-    tbl2.join(tbl1, cols=('a'))
-    assert list(tbl2.keys()) == ['c', 'd', 'a']
+    tbl2.join(tbl1, cols=("a"))
+    assert list(tbl2.keys()) == ["c", "d", "a"]
 
 
 def test_get_dataframe():
     tbl = Table(3)
-    tbl.add_column('a', lgdo.Array(np.array([1, 2, 3])))
+    tbl.add_column("a", lgdo.Array(np.array([1, 2, 3])))
     df = tbl.get_dataframe()
     assert isinstance(df, pd.DataFrame)
-    assert df.keys().all() == 'a'
+    assert df.keys().all() == "a"
diff --git a/tests/lgdo/test_vectorofvectors.py b/tests/lgdo/test_vectorofvectors.py
index 7a9a370f0..b6b048d7a 100644
--- a/tests/lgdo/test_vectorofvectors.py
+++ b/tests/lgdo/test_vectorofvectors.py
@@ -7,8 +7,10 @@
 @pytest.fixture()
 def lgdo_vov():
     return lgdo.VectorOfVectors(
-        flattened_data=lgdo.Array(nda=np.array([1, 2, 3, 4, 5, 2, 4, 8, 9, 7, 5, 3, 1])),
-        cumulative_length=lgdo.Array(nda=np.array([2, 5, 6, 10, 13]))
+        flattened_data=lgdo.Array(
+            nda=np.array([1, 2, 3, 4, 5, 2, 4, 8, 9, 7, 5, 3, 1])
+        ),
+        cumulative_length=lgdo.Array(nda=np.array([2, 5, 6, 10, 13])),
     )
 
 
@@ -17,19 +19,21 @@ def test_init(lgdo_vov):
 
 
 def test_datatype_name(lgdo_vov):
-    assert lgdo_vov.datatype_name() == 'array'
+    assert lgdo_vov.datatype_name() == "array"
 
 
 def test_form_datatype(lgdo_vov):
-    assert lgdo_vov.form_datatype() == 'array<1>{array<1>{real}}'
+    assert lgdo_vov.form_datatype() == "array<1>{array<1>{real}}"
 
 
 def test_values(lgdo_vov):
-    desired = [np.array([1, 2]),
-               np.array([3, 4, 5]),
-               np.array([2]),
-               np.array([4, 8, 9, 7]),
-               np.array([5, 3, 1])]
+    desired = [
+        np.array([1, 2]),
+        np.array([3, 4, 5]),
+        np.array([2]),
+        np.array([4, 8, 9, 7]),
+        np.array([5, 3, 1]),
+    ]
 
     for i in range(len(desired)):
         assert (desired[i] == list(lgdo_vov)[i]).all()
@@ -43,24 +47,63 @@ def test_resize(lgdo_vov):
 def test_set_vector(lgdo_vov):
     lgdo_vov.set_vector(0, np.zeros(2))
 
-    desired = [np.zeros(2),
-               np.array([3, 4, 5]),
-               np.array([2]),
-               np.array([4, 8, 9, 7]),
-               np.array([5, 3, 1])]
+    desired = [
+        np.zeros(2),
+        np.array([3, 4, 5]),
+        np.array([2]),
+        np.array([4, 8, 9, 7]),
+        np.array([5, 3, 1]),
+    ]
 
     for i in range(len(desired)):
         assert (desired[i] == list(lgdo_vov)[i]).all()
 
 
 def test_iter(lgdo_vov):
-    desired = [np.array([1, 2]),
-               np.array([3, 4, 5]),
-               np.array([2]),
-               np.array([4, 8, 9, 7]),
-               np.array([5, 3, 1])]
+    desired = [
+        np.array([1, 2]),
+        np.array([3, 4, 5]),
+        np.array([2]),
+        np.array([4, 8, 9, 7]),
+        np.array([5, 3, 1]),
+    ]
 
     c = 0
     for v in lgdo_vov:
         assert (v == desired[c]).all()
         c += 1
+
+
+def test_build_cl_and_explodes():
+    cl = np.array([3, 4], dtype=np.uint64)
+    exp = np.array([0, 0, 0, 1], dtype=np.uint64)
+    array = np.array([5, 7], dtype=np.uint64)
+    array_exp = np.array([5, 5, 5, 7], dtype=np.uint64)
+    # build_cl
+    assert (lgdo.build_cl(exp, cl) == cl).all()
+    assert (lgdo.build_cl(exp) == cl).all()
+    assert (lgdo.build_cl([0, 0, 0, 1]) == cl).all()
+    assert (lgdo.build_cl(array_exp, cl) == cl).all()
+    assert (lgdo.build_cl(array_exp) == cl).all()
+    assert (lgdo.build_cl([5, 5, 5, 7]) == cl).all()
+    # explode_cl
+    assert (lgdo.explode_cl(cl, exp) == exp).all()
+    assert (lgdo.explode_cl(cl) == exp).all()
+    assert (lgdo.explode_cl([3, 4]) == exp).all()
+    # inverse functionality
+    assert (lgdo.build_cl(lgdo.explode_cl(cl)) == cl).all()
+    assert (lgdo.explode_cl(lgdo.build_cl(array_exp)) == exp).all()
+    # explode
+    assert (lgdo.explode(cl, array, array_exp) == array_exp).all()
+    assert (lgdo.explode(cl, array) == array_exp).all()
+    assert (lgdo.explode([3, 4], [5, 7]) == array_exp).all()
+    assert (lgdo.explode(cl, range(len(cl))) == exp).all()
+    # explode_arrays
+    out_arrays = lgdo.explode_arrays(cl, [array, range(len(cl))])
+    assert len(out_arrays) == 2
+    assert (out_arrays[0] == array_exp).all()
+    assert (out_arrays[1] == exp).all()
+    out_arrays = lgdo.explode_arrays(cl, [array, range(len(cl))], out_arrays=out_arrays)
+    assert len(out_arrays) == 2
+    assert (out_arrays[0] == array_exp).all()
+    assert (out_arrays[1] == exp).all()
diff --git a/tests/lgdo/test_waveform_table.py b/tests/lgdo/test_waveform_table.py
index e548e6bcb..f59d2d9f3 100644
--- a/tests/lgdo/test_waveform_table.py
+++ b/tests/lgdo/test_waveform_table.py
@@ -10,11 +10,11 @@ def test_init():
     assert (wft.dt.nda == np.full(1024, fill_value=1)).all()
     assert isinstance(wft.values, lgdo.VectorOfVectors)
     assert len(wft.values) == 1024
-    assert wft.attrs == {'datatype': 'table{t0,dt,values}'}
+    assert wft.attrs == {"datatype": "table{t0,dt,values}"}
 
-    wft = WaveformTable(dt_units='ns', values_units='adc')
-    assert wft.dt.attrs['units'] == 'ns'
-    assert wft.values.attrs['units'] == 'adc'
+    wft = WaveformTable(dt_units="ns", values_units="adc")
+    assert wft.dt.attrs["units"] == "ns"
+    assert wft.values.attrs["units"] == "adc"
 
     wft = WaveformTable(size=10, wf_len=1000)
     assert (wft.t0.nda == np.zeros(10)).all()
@@ -23,7 +23,9 @@ def test_init():
     assert (wft.values.nda == np.zeros(shape=(10, 1000))).all()
     assert wft.values.nda.dtype == np.float64
 
-    wft = WaveformTable(values=lgdo.ArrayOfEqualSizedArrays(shape=(10, 1000), fill_val=69))
+    wft = WaveformTable(
+        values=lgdo.ArrayOfEqualSizedArrays(shape=(10, 1000), fill_val=69)
+    )
     assert (wft.t0.nda == np.zeros(10)).all()
     assert (wft.dt.nda == np.full(10, fill_value=1)).all()
     assert isinstance(wft.values, lgdo.ArrayOfEqualSizedArrays)
@@ -35,13 +37,17 @@ def test_init():
     assert isinstance(wft.values, lgdo.ArrayOfEqualSizedArrays)
     assert (wft.values.nda == np.full(shape=(10, 1000), fill_value=69)).all()
 
-    wft = WaveformTable(values=lgdo.VectorOfVectors(shape_guess=(10, 1000), dtype=np.float32))
+    wft = WaveformTable(
+        values=lgdo.VectorOfVectors(shape_guess=(10, 1000), dtype=np.float32)
+    )
     assert (wft.t0.nda == np.zeros(10)).all()
     assert (wft.dt.nda == np.full(10, fill_value=1)).all()
     assert isinstance(wft.values, lgdo.VectorOfVectors)
     assert len(wft.values) == 10
 
-    wft = WaveformTable(t0=[1, 1, 1], dt=[2, 2, 2], values=lgdo.ArrayOfEqualSizedArrays(shape=(3, 1000)))
+    wft = WaveformTable(
+        t0=[1, 1, 1], dt=[2, 2, 2], values=lgdo.ArrayOfEqualSizedArrays(shape=(3, 1000))
+    )
     assert (wft.t0.nda == np.full(3, fill_value=1)).all()
     assert (wft.dt.nda == np.full(3, fill_value=2)).all()
     assert isinstance(wft.values, lgdo.ArrayOfEqualSizedArrays)
diff --git a/tests/math/test_fwhm.py b/tests/math/test_fwhm.py
index 9ef238d84..0d9164906 100644
--- a/tests/math/test_fwhm.py
+++ b/tests/math/test_fwhm.py
@@ -6,29 +6,33 @@
 
 def test_mostly_gauss_fwhm():
     pars = [0, 1, 0, 0, 0.1, 0, 1]
-    cov = [[1e-16, 0, 0, 0, 0, 0, 0], # dmu2
-           [0, 1e-02, 0, 0, 0, 0, 0], # dsig2
-           [0, 0, 1e-16, 0, 0, 0, 0], # dhstep2
-           [0, 0, 0, 1e-16, 0, 0, 0], # dhtail2
-           [0, 0, 0, 0, 1e-16, 0, 0], # dtau2
-           [0, 0, 0, 0, 0, 1e-16, 0], # dbg02
-           [0, 0, 0, 0, 0, 0, 1e-16]] # damp2
+    cov = [
+        [1e-16, 0, 0, 0, 0, 0, 0],  # dmu2
+        [0, 1e-02, 0, 0, 0, 0, 0],  # dsig2
+        [0, 0, 1e-16, 0, 0, 0, 0],  # dhstep2
+        [0, 0, 0, 1e-16, 0, 0, 0],  # dhtail2
+        [0, 0, 0, 0, 1e-16, 0, 0],  # dtau2
+        [0, 0, 0, 0, 0, 1e-16, 0],  # dbg02
+        [0, 0, 0, 0, 0, 0, 1e-16],
+    ]  # damp2
     mu, sig, hstep, htail, tau, bg0, amp = pars
     fwhm, dfwhm = pgb.hpge_peak_fwhm(sig, htail, tau, cov)
-    assert fwhm == approx(2.3548, rel=1E-5)
-    assert dfwhm == approx(2.3548E-8, rel=1E-5)
+    assert fwhm == approx(2.3548, rel=1e-5)
+    assert dfwhm == approx(2.3548e-8, rel=1e-5)
 
 
 def test_mostly_exp_fwhm():
     pars = [0, 1e-6, 0, 1, 1, 0, 1]
-    cov = [[1e-16, 0, 0, 0, 0, 0, 0], # dmu2
-           [0, 1e-16, 0, 0, 0, 0, 0], # dsig2
-           [0, 0, 1e-16, 0, 0, 0, 0], # dhstep2
-           [0, 0, 0, 1e-16, 0, 0, 0], # dhtail2
-           [0, 0, 0, 0, 1e-02, 0, 0], # dtau2
-           [0, 0, 0, 0, 0, 1e-16, 0], # dbg02
-           [0, 0, 0, 0, 0, 0, 1e-16]] # damp2
+    cov = [
+        [1e-16, 0, 0, 0, 0, 0, 0],  # dmu2
+        [0, 1e-16, 0, 0, 0, 0, 0],  # dsig2
+        [0, 0, 1e-16, 0, 0, 0, 0],  # dhstep2
+        [0, 0, 0, 1e-16, 0, 0, 0],  # dhtail2
+        [0, 0, 0, 0, 1e-02, 0, 0],  # dtau2
+        [0, 0, 0, 0, 0, 1e-16, 0],  # dbg02
+        [0, 0, 0, 0, 0, 0, 1e-16],
+    ]  # damp2
     mu, sig, hstep, htail, tau, bg0, amp = pars
     fwhm, dfwhm = pgb.hpge_peak_fwhm(sig, htail, tau, cov)
-    assert fwhm == approx(np.log(2), rel=1E-5)
-    assert dfwhm == approx(np.log(2)/10, rel=1E-5)
+    assert fwhm == approx(np.log(2), rel=1e-5)
+    assert dfwhm == approx(np.log(2) / 10, rel=1e-5)
diff --git a/tests/pargen/test_ecal.py b/tests/pargen/test_ecal.py
index ea194e059..9f9e1d7c9 100644
--- a/tests/pargen/test_ecal.py
+++ b/tests/pargen/test_ecal.py
@@ -1,14 +1,22 @@
 import numpy as np
-import pytest
 
-import pygama.math.binned_fitting as pf
-import pygama.math.histogram as pgh
 from pygama.pargen import energy_cal
 
 
 def test_peak_match():
     expected_peaks_kev = [1460, 2614.5]
-    peaks_adu = [78.676315, 288.4798, 603.18506, 1337.4973, 2019.3586,
-                 3225.7288, 3907.59, 5795.8213, 8470.816]
-    pars, best_ixtup, best_iytup = energy_cal.poly_match(peaks_adu, expected_peaks_kev, deg=0, atol=10)
+    peaks_adu = [
+        78.676315,
+        288.4798,
+        603.18506,
+        1337.4973,
+        2019.3586,
+        3225.7288,
+        3907.59,
+        5795.8213,
+        8470.816,
+    ]
+    pars, best_ixtup, best_iytup = energy_cal.poly_match(
+        peaks_adu, expected_peaks_kev, deg=0, atol=10
+    )
     assert np.array_equal(best_ixtup, [5, 7])
diff --git a/tests/raw/configs/fc-out-spec.json b/tests/raw/configs/fc-out-spec.json
index 260c8930f..ebfbb4343 100644
--- a/tests/raw/configs/fc-out-spec.json
+++ b/tests/raw/configs/fc-out-spec.json
@@ -1,8 +1,8 @@
 {
-    "FCEventDecoder": {
-        "spms": {
-            "key_list": [[2, 4]],
-            "out_stream": "/tmp/L200-comm-20211130-phy-spms.lh5"
-        }
+  "FCEventDecoder": {
+    "spms": {
+      "key_list": [[2, 4]],
+      "out_stream": "/tmp/L200-comm-20211130-phy-spms.lh5"
     }
+  }
 }
diff --git a/tests/raw/configs/orca-out-spec-cli.json b/tests/raw/configs/orca-out-spec-cli.json
index 141f32698..3e536c71b 100644
--- a/tests/raw/configs/orca-out-spec-cli.json
+++ b/tests/raw/configs/orca-out-spec-cli.json
@@ -1,8 +1,8 @@
 {
-    "ORFlashCamADCWaveformDecoder": {
-        "g{key}": {
-            "key_list": [[0, 3]],
-            "out_stream": "/tmp/{orig_basename}.lh5"
-        }
+  "ORFlashCamADCWaveformDecoder": {
+    "g{key}": {
+      "key_list": [[0, 3]],
+      "out_stream": "/tmp/{orig_basename}.lh5"
     }
+  }
 }
diff --git a/tests/raw/configs/orca-out-spec.json b/tests/raw/configs/orca-out-spec.json
index da2be4985..208a506c7 100644
--- a/tests/raw/configs/orca-out-spec.json
+++ b/tests/raw/configs/orca-out-spec.json
@@ -1,8 +1,8 @@
 {
-    "ORFlashCamADCWaveformDecoder": {
-        "geds": {
-            "key_list": [[0, 3]],
-            "out_stream": "/tmp/L200-comm-20220519-phy-geds.lh5"
-        }
+  "ORFlashCamADCWaveformDecoder": {
+    "geds": {
+      "key_list": [[0, 3]],
+      "out_stream": "/tmp/L200-comm-20220519-phy-geds.lh5"
     }
+  }
 }
diff --git a/tests/raw/fc/conftest.py b/tests/raw/fc/conftest.py
index 0a344e683..d759d16c7 100644
--- a/tests/raw/fc/conftest.py
+++ b/tests/raw/fc/conftest.py
@@ -4,12 +4,14 @@
 from pygama.raw.fc.fc_config_decoder import FCConfigDecoder
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def fcio_obj(lgnd_test_data):
-    return fcutils.fcio(lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'))
+    return fcutils.fcio(
+        lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio")
+    )
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def fcio_config(fcio_obj):
     decoder = FCConfigDecoder()
     decoder.decode_config(fcio_obj)
diff --git a/tests/raw/fc/test_fc_config_decoder.py b/tests/raw/fc/test_fc_config_decoder.py
index a5eb0dfc2..0a3e081f3 100644
--- a/tests/raw/fc/test_fc_config_decoder.py
+++ b/tests/raw/fc/test_fc_config_decoder.py
@@ -11,17 +11,17 @@ def test_data_types(fcio_config):
 
 def test_values(fcio_config):
     expected_dict = {
-        'nsamples': 6000,
-        'nadcs': 6,
-        'ntriggers': 0,
-        'telid': 0,
-        'adcbits': 16,
-        'sumlength': 1,
-        'blprecision': 1,
-        'mastercards': 1,
-        'triggercards': 0,
-        'adccards': 1,
-        'gps': 0
+        "nsamples": 6000,
+        "nadcs": 6,
+        "ntriggers": 0,
+        "telid": 0,
+        "adcbits": 16,
+        "sumlength": 1,
+        "blprecision": 1,
+        "mastercards": 1,
+        "triggercards": 0,
+        "adccards": 1,
+        "gps": 0,
     }
 
     for k, v in expected_dict.items():
diff --git a/tests/raw/fc/test_fc_event_decoder.py b/tests/raw/fc/test_fc_event_decoder.py
index 8c0c9fff4..91182015f 100644
--- a/tests/raw/fc/test_fc_event_decoder.py
+++ b/tests/raw/fc/test_fc_event_decoder.py
@@ -30,39 +30,39 @@ def test_decoding(event_rbkd):
 
 def test_data_types(event_rbkd):
 
-    for k, v in event_rbkd.items():
+    for _, v in event_rbkd.items():
         # assert v.out_name == 'FCEvent' FIXME: is this a bug?
         tbl = v.lgdo
         assert isinstance(tbl, lgdo.Struct)
-        assert isinstance(tbl['packet_id'], lgdo.Array)
-        assert isinstance(tbl['eventnumber'], lgdo.Array)
-        assert isinstance(tbl['timestamp'], lgdo.Array)
-        assert isinstance(tbl['runtime'], lgdo.Array)
-        assert isinstance(tbl['numtraces'], lgdo.Array)
-        assert isinstance(tbl['tracelist'], lgdo.VectorOfVectors)
-        assert isinstance(tbl['baseline'], lgdo.Array)
-        assert isinstance(tbl['daqenergy'], lgdo.Array)
-        assert isinstance(tbl['channel'], lgdo.Array)
-        assert isinstance(tbl['ts_pps'], lgdo.Array)
-        assert isinstance(tbl['ts_ticks'], lgdo.Array)
-        assert isinstance(tbl['ts_maxticks'], lgdo.Array)
-        assert isinstance(tbl['to_mu_sec'], lgdo.Array)
-        assert isinstance(tbl['to_mu_usec'], lgdo.Array)
-        assert isinstance(tbl['to_master_sec'], lgdo.Array)
-        assert isinstance(tbl['to_dt_mu_usec'], lgdo.Array)
-        assert isinstance(tbl['to_abs_mu_usec'], lgdo.Array)
-        assert isinstance(tbl['to_start_sec'], lgdo.Array)
-        assert isinstance(tbl['to_start_usec'], lgdo.Array)
-        assert isinstance(tbl['dr_start_pps'], lgdo.Array)
-        assert isinstance(tbl['dr_start_ticks'], lgdo.Array)
-        assert isinstance(tbl['dr_stop_pps'], lgdo.Array)
-        assert isinstance(tbl['dr_stop_ticks'], lgdo.Array)
-        assert isinstance(tbl['dr_maxticks'], lgdo.Array)
-        assert isinstance(tbl['deadtime'], lgdo.Array)
-        assert isinstance(tbl['waveform'], lgdo.Struct)
-        assert isinstance(tbl['waveform']['t0'], lgdo.Array)
-        assert isinstance(tbl['waveform']['dt'], lgdo.Array)
-        assert isinstance(tbl['waveform']['values'], lgdo.ArrayOfEqualSizedArrays)
+        assert isinstance(tbl["packet_id"], lgdo.Array)
+        assert isinstance(tbl["eventnumber"], lgdo.Array)
+        assert isinstance(tbl["timestamp"], lgdo.Array)
+        assert isinstance(tbl["runtime"], lgdo.Array)
+        assert isinstance(tbl["numtraces"], lgdo.Array)
+        assert isinstance(tbl["tracelist"], lgdo.VectorOfVectors)
+        assert isinstance(tbl["baseline"], lgdo.Array)
+        assert isinstance(tbl["daqenergy"], lgdo.Array)
+        assert isinstance(tbl["channel"], lgdo.Array)
+        assert isinstance(tbl["ts_pps"], lgdo.Array)
+        assert isinstance(tbl["ts_ticks"], lgdo.Array)
+        assert isinstance(tbl["ts_maxticks"], lgdo.Array)
+        assert isinstance(tbl["to_mu_sec"], lgdo.Array)
+        assert isinstance(tbl["to_mu_usec"], lgdo.Array)
+        assert isinstance(tbl["to_master_sec"], lgdo.Array)
+        assert isinstance(tbl["to_dt_mu_usec"], lgdo.Array)
+        assert isinstance(tbl["to_abs_mu_usec"], lgdo.Array)
+        assert isinstance(tbl["to_start_sec"], lgdo.Array)
+        assert isinstance(tbl["to_start_usec"], lgdo.Array)
+        assert isinstance(tbl["dr_start_pps"], lgdo.Array)
+        assert isinstance(tbl["dr_start_ticks"], lgdo.Array)
+        assert isinstance(tbl["dr_stop_pps"], lgdo.Array)
+        assert isinstance(tbl["dr_stop_ticks"], lgdo.Array)
+        assert isinstance(tbl["dr_maxticks"], lgdo.Array)
+        assert isinstance(tbl["deadtime"], lgdo.Array)
+        assert isinstance(tbl["waveform"], lgdo.Struct)
+        assert isinstance(tbl["waveform"]["t0"], lgdo.Array)
+        assert isinstance(tbl["waveform"]["dt"], lgdo.Array)
+        assert isinstance(tbl["waveform"]["values"], lgdo.ArrayOfEqualSizedArrays)
 
 
 def test_values(event_rbkd, fcio_obj):
@@ -74,36 +74,38 @@ def test_values(event_rbkd, fcio_obj):
 
         assert event_rbkd[ch].fill_safety == fc.numtraces
 
-        assert tbl['packet_id'].nda[loc] == 69
-        assert tbl['eventnumber'].nda[loc] == fc.eventnumber
-        assert tbl['timestamp'].nda[loc] == fc.eventtime
-        assert tbl['runtime'].nda[loc] == fc.runtime
-        assert tbl['numtraces'].nda[loc] == fc.numtraces
+        assert tbl["packet_id"].nda[loc] == 69
+        assert tbl["eventnumber"].nda[loc] == fc.eventnumber
+        assert tbl["timestamp"].nda[loc] == fc.eventtime
+        assert tbl["runtime"].nda[loc] == fc.runtime
+        assert tbl["numtraces"].nda[loc] == fc.numtraces
 
         # custom logic for VectorOfVectors
-        start = 0 if loc == 0 else tbl['tracelist'].cumulative_length.nda[loc-1]
+        start = 0 if loc == 0 else tbl["tracelist"].cumulative_length.nda[loc - 1]
         stop = start + len(fc.tracelist)
-        assert np.array_equal(tbl['tracelist'].flattened_data.nda[start:stop], fc.tracelist)
-
-        assert np.array_equal(tbl['baseline'].nda[loc], fc.baseline[ch])
-        assert np.array_equal(tbl['daqenergy'].nda[loc], fc.daqenergy[ch])
-        assert tbl['channel'].nda[loc] == ch
-        assert tbl['ts_pps'].nda[loc] == fc.timestamp_pps
-        assert tbl['ts_ticks'].nda[loc] == fc.timestamp_ticks
-        assert tbl['ts_maxticks'].nda[loc] == fc.timestamp_maxticks
-        assert tbl['to_mu_sec'].nda[loc] == fc.timeoffset_mu_sec
-        assert tbl['to_mu_usec'].nda[loc] == fc.timeoffset_mu_usec
-        assert tbl['to_master_sec'].nda[loc] == fc.timeoffset_master_sec
-        assert tbl['to_dt_mu_usec'].nda[loc] == fc.timeoffset_dt_mu_usec
-        assert tbl['to_abs_mu_usec'].nda[loc] == fc.timeoffset_abs_mu_usec
-        assert tbl['to_start_sec'].nda[loc] == fc.timeoffset_start_sec
-        assert tbl['to_start_usec'].nda[loc] == fc.timeoffset_start_usec
-        assert tbl['dr_start_pps'].nda[loc] == fc.deadregion_start_pps
-        assert tbl['dr_start_ticks'].nda[loc] == fc.deadregion_start_ticks
-        assert tbl['dr_stop_pps'].nda[loc] == fc.deadregion_stop_pps
-        assert tbl['dr_stop_ticks'].nda[loc] == fc.deadregion_stop_ticks
-        assert tbl['dr_maxticks'].nda[loc] == fc.deadregion_maxticks
-        assert tbl['deadtime'].nda[loc] == fc.deadtime
-        assert tbl['waveform']['t0'].nda[loc] == 0
-        assert tbl['waveform']['dt'].nda[loc] == 16
-        assert np.array_equal(tbl['waveform']['values'].nda[loc], fc.traces[ch])
+        assert np.array_equal(
+            tbl["tracelist"].flattened_data.nda[start:stop], fc.tracelist
+        )
+
+        assert np.array_equal(tbl["baseline"].nda[loc], fc.baseline[ch])
+        assert np.array_equal(tbl["daqenergy"].nda[loc], fc.daqenergy[ch])
+        assert tbl["channel"].nda[loc] == ch
+        assert tbl["ts_pps"].nda[loc] == fc.timestamp_pps
+        assert tbl["ts_ticks"].nda[loc] == fc.timestamp_ticks
+        assert tbl["ts_maxticks"].nda[loc] == fc.timestamp_maxticks
+        assert tbl["to_mu_sec"].nda[loc] == fc.timeoffset_mu_sec
+        assert tbl["to_mu_usec"].nda[loc] == fc.timeoffset_mu_usec
+        assert tbl["to_master_sec"].nda[loc] == fc.timeoffset_master_sec
+        assert tbl["to_dt_mu_usec"].nda[loc] == fc.timeoffset_dt_mu_usec
+        assert tbl["to_abs_mu_usec"].nda[loc] == fc.timeoffset_abs_mu_usec
+        assert tbl["to_start_sec"].nda[loc] == fc.timeoffset_start_sec
+        assert tbl["to_start_usec"].nda[loc] == fc.timeoffset_start_usec
+        assert tbl["dr_start_pps"].nda[loc] == fc.deadregion_start_pps
+        assert tbl["dr_start_ticks"].nda[loc] == fc.deadregion_start_ticks
+        assert tbl["dr_stop_pps"].nda[loc] == fc.deadregion_stop_pps
+        assert tbl["dr_stop_ticks"].nda[loc] == fc.deadregion_stop_ticks
+        assert tbl["dr_maxticks"].nda[loc] == fc.deadregion_maxticks
+        assert tbl["deadtime"].nda[loc] == fc.deadtime
+        assert tbl["waveform"]["t0"].nda[loc] == 0
+        assert tbl["waveform"]["dt"].nda[loc] == 16
+        assert np.array_equal(tbl["waveform"]["values"].nda[loc], fc.traces[ch])
diff --git a/tests/raw/fc/test_fc_status_decoder.py b/tests/raw/fc/test_fc_status_decoder.py
index c6b9267e7..3dd141756 100644
--- a/tests/raw/fc/test_fc_status_decoder.py
+++ b/tests/raw/fc/test_fc_status_decoder.py
@@ -1,4 +1,3 @@
-import fcutils
 import numpy as np
 import pytest
 from pytest import approx
@@ -8,7 +7,7 @@
 from pygama.raw.raw_buffer import RawBuffer
 
 
-@pytest.fixture(scope='module')
+@pytest.fixture(scope="module")
 def status_rb(fcio_obj):
     decoder = FCStatusDecoder()
     rb = RawBuffer(lgdo=decoder.make_lgdo(size=1))
@@ -26,24 +25,23 @@ def status_rb(fcio_obj):
 
 def test_decoding(status_rb):
     assert status_rb.is_full() is True
-    pass
 
 
 def test_data_types(status_rb):
     tbl = status_rb.lgdo
 
-    assert isinstance(tbl['status'], lgdo.Array)
-    assert isinstance(tbl['statustime'], lgdo.Array)
-    assert isinstance(tbl['cputime'], lgdo.Array)
-    assert isinstance(tbl['startoffset'], lgdo.Array)
-    assert isinstance(tbl['cards'], lgdo.Array)
-    assert isinstance(tbl['size'], lgdo.Array)
-    assert isinstance(tbl['environment'], lgdo.ArrayOfEqualSizedArrays)
-    assert isinstance(tbl['totalerrors'], lgdo.Array)
-    assert isinstance(tbl['linkerrors'], lgdo.Array)
-    assert isinstance(tbl['ctierrors'], lgdo.Array)
-    assert isinstance(tbl['enverrors'], lgdo.Array)
-    assert isinstance(tbl['othererrors'], lgdo.ArrayOfEqualSizedArrays)
+    assert isinstance(tbl["status"], lgdo.Array)
+    assert isinstance(tbl["statustime"], lgdo.Array)
+    assert isinstance(tbl["cputime"], lgdo.Array)
+    assert isinstance(tbl["startoffset"], lgdo.Array)
+    assert isinstance(tbl["cards"], lgdo.Array)
+    assert isinstance(tbl["size"], lgdo.Array)
+    assert isinstance(tbl["environment"], lgdo.ArrayOfEqualSizedArrays)
+    assert isinstance(tbl["totalerrors"], lgdo.Array)
+    assert isinstance(tbl["linkerrors"], lgdo.Array)
+    assert isinstance(tbl["ctierrors"], lgdo.Array)
+    assert isinstance(tbl["enverrors"], lgdo.Array)
+    assert isinstance(tbl["othererrors"], lgdo.ArrayOfEqualSizedArrays)
 
 
 def test_values(status_rb, fcio_obj):
@@ -52,15 +50,17 @@ def test_values(status_rb, fcio_obj):
     i = status_rb.loc - 1
 
     assert i == 0
-    assert tbl['status'].nda[i] == fc.status
-    assert tbl['statustime'].nda[i] == approx(fc.statustime[0]+fc.statustime[1]/1e6)
-    assert tbl['cputime'].nda[i] == approx(fc.statustime[2]+fc.statustime[3]/1e6)
-    assert tbl['startoffset'].nda[i] == approx(fc.statustime[5]+fc.statustime[6]/1e6)
-    assert tbl['cards'].nda[i] == fc.cards
-    assert tbl['size'].nda[i] == fc.size
-    assert np.array_equal(tbl['environment'].nda[i], fc.environment)
-    assert tbl['totalerrors'].nda[i] == fc.totalerrors
-    assert tbl['linkerrors'].nda[i] == fc.linkerrors
-    assert tbl['ctierrors'].nda[i] == fc.ctierrors
-    assert tbl['enverrors'].nda[i] == fc.enverrors
-    assert np.array_equal(tbl['othererrors'].nda[i], fc.othererrors)
+    assert tbl["status"].nda[i] == fc.status
+    assert tbl["statustime"].nda[i] == approx(fc.statustime[0] + fc.statustime[1] / 1e6)
+    assert tbl["cputime"].nda[i] == approx(fc.statustime[2] + fc.statustime[3] / 1e6)
+    assert tbl["startoffset"].nda[i] == approx(
+        fc.statustime[5] + fc.statustime[6] / 1e6
+    )
+    assert tbl["cards"].nda[i] == fc.cards
+    assert tbl["size"].nda[i] == fc.size
+    assert np.array_equal(tbl["environment"].nda[i], fc.environment)
+    assert tbl["totalerrors"].nda[i] == fc.totalerrors
+    assert tbl["linkerrors"].nda[i] == fc.linkerrors
+    assert tbl["ctierrors"].nda[i] == fc.ctierrors
+    assert tbl["enverrors"].nda[i] == fc.enverrors
+    assert np.array_equal(tbl["othererrors"].nda[i], fc.othererrors)
diff --git a/tests/raw/fc/test_fc_streamer.py b/tests/raw/fc/test_fc_streamer.py
index c666e5e2f..050a99f52 100644
--- a/tests/raw/fc/test_fc_streamer.py
+++ b/tests/raw/fc/test_fc_streamer.py
@@ -14,58 +14,70 @@ def test_get_decoder_list():
 
 def test_default_rb_lib(lgnd_test_data):
     streamer = FCStreamer()
-    streamer.open_stream(lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'), buffer_size=6)
+    streamer.open_stream(
+        lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"), buffer_size=6
+    )
     rb_lib = streamer.build_default_rb_lib()
-    assert 'FCConfigDecoder' in rb_lib.keys()
-    assert 'FCStatusDecoder' in rb_lib.keys()
-    assert 'FCEventDecoder' in rb_lib.keys()
-    assert rb_lib['FCConfigDecoder'][0].out_name == 'FCConfig'
-    assert rb_lib['FCStatusDecoder'][0].out_name == 'FCStatus'
-    assert rb_lib['FCEventDecoder'][0].out_name == 'FCEvent'
-    assert rb_lib['FCEventDecoder'][0].key_list == range(0, 6)
+    assert "FCConfigDecoder" in rb_lib.keys()
+    assert "FCStatusDecoder" in rb_lib.keys()
+    assert "FCEventDecoder" in rb_lib.keys()
+    assert rb_lib["FCConfigDecoder"][0].out_name == "FCConfig"
+    assert rb_lib["FCStatusDecoder"][0].out_name == "FCStatus"
+    assert rb_lib["FCEventDecoder"][0].out_name == "FCEvent"
+    assert rb_lib["FCEventDecoder"][0].key_list == range(0, 6)
 
 
 def test_open_stream(lgnd_test_data):
     streamer = FCStreamer()
-    res = streamer.open_stream(lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'), buffer_size=6)
+    res = streamer.open_stream(
+        lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"), buffer_size=6
+    )
     assert isinstance(res[0], RawBuffer)
     assert streamer.fcio is not None  # fcio object is instantiated
     assert streamer.packet_id == 0  # packet id is initialized
-    assert streamer.n_bytes_read == 11*4  # fc header is read
+    assert streamer.n_bytes_read == 11 * 4  # fc header is read
     assert streamer.event_rbkd is not None  # dict containing event info is initialized
 
 
 def test_read_packet(lgnd_test_data):
     streamer = FCStreamer()
-    streamer.open_stream(lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'),
-                         buffer_size=6)
+    streamer.open_stream(
+        lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"), buffer_size=6
+    )
     init_rbytes = streamer.n_bytes_read
     assert streamer.read_packet() is True  # read was successful
     assert streamer.packet_id == 1  # packet id is incremented
-    assert streamer.n_bytes_read == init_rbytes + 144 + 2 \
-        * streamer.fcio.numtraces * (streamer.fcio.nsamples + 3)
+    assert streamer.n_bytes_read == init_rbytes + 144 + 2 * streamer.fcio.numtraces * (
+        streamer.fcio.nsamples + 3
+    )
 
 
 def test_read_packet_partial(lgnd_test_data):
     streamer = FCStreamer()
-    rb_lib = {'FCEventDecoder': RawBufferList()}
-    rb_lib['FCEventDecoder'].append(RawBuffer(key_list=range(2, 3), out_name='events'))
+    rb_lib = {"FCEventDecoder": RawBufferList()}
+    rb_lib["FCEventDecoder"].append(RawBuffer(key_list=range(2, 3), out_name="events"))
 
-    streamer.open_stream(lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'),
-                         rb_lib=rb_lib, buffer_size=6)
+    streamer.open_stream(
+        lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
+        rb_lib=rb_lib,
+        buffer_size=6,
+    )
 
-    assert list(streamer.rb_lib.keys()) == ['FCEventDecoder']
-    assert streamer.rb_lib['FCEventDecoder'][0].key_list == range(2, 3)
-    assert streamer.rb_lib['FCEventDecoder'][0].out_name == 'events'
+    assert list(streamer.rb_lib.keys()) == ["FCEventDecoder"]
+    assert streamer.rb_lib["FCEventDecoder"][0].key_list == range(2, 3)
+    assert streamer.rb_lib["FCEventDecoder"][0].out_name == "events"
 
     init_rbytes = streamer.n_bytes_read
     assert streamer.read_packet() is True  # read was successful
     assert streamer.packet_id == 1  # packet id is incremented
-    assert streamer.n_bytes_read == init_rbytes + 144 + 2 \
-        * streamer.fcio.numtraces * (streamer.fcio.nsamples + 3)
+    assert streamer.n_bytes_read == init_rbytes + 144 + 2 * streamer.fcio.numtraces * (
+        streamer.fcio.nsamples + 3
+    )
 
 
 def test_read_chunk(lgnd_test_data):
     streamer = FCStreamer()
-    streamer.open_stream(lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'), buffer_size=6)
+    streamer.open_stream(
+        lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"), buffer_size=6
+    )
     streamer.read_chunk()
diff --git a/tests/raw/orca/test_orca_packet.py b/tests/raw/orca/test_orca_packet.py
new file mode 100644
index 000000000..b821d427b
--- /dev/null
+++ b/tests/raw/orca/test_orca_packet.py
@@ -0,0 +1,2 @@
+def test_orca_packet_import():
+    pass
diff --git a/tests/raw/test_build_raw.py b/tests/raw/test_build_raw.py
index 592889b92..cb46f6f13 100644
--- a/tests/raw/test_build_raw.py
+++ b/tests/raw/test_build_raw.py
@@ -1,113 +1,142 @@
-import os.path
+import os
 from pathlib import Path
 
 import pytest
 
-import pygama.raw as raw
 from pygama.lgdo.lh5_store import LH5Store, ls
+from pygama.raw import build_raw
 
-config_dir = Path(__file__).parent/'configs'
+config_dir = Path(__file__).parent / "configs"
 
 
 def test_build_raw_basics(lgnd_test_data):
     with pytest.raises(FileNotFoundError):
-        raw.build_raw(in_stream='non-existent-file')
+        build_raw(in_stream="non-existent-file")
 
     with pytest.raises(FileNotFoundError):
-        raw.build_raw(in_stream=lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'),
-                      out_spec='non-existent-file.json')
+        build_raw(
+            in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
+            out_spec="non-existent-file.json",
+        )
 
 
 def test_build_raw_fc(lgnd_test_data):
-    raw.build_raw(in_stream=lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'))
+    build_raw(
+        in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
+        overwrite=True,
+    )
 
-    assert lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.lh5') != ''
+    assert lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.lh5") != ""
 
-    out_file = '/tmp/L200-comm-20211130-phy-spms.lh5'
+    out_file = "/tmp/L200-comm-20211130-phy-spms.lh5"
 
-    raw.build_raw(in_stream=lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'),
-                  out_spec=out_file)
+    build_raw(
+        in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
+        out_spec=out_file,
+        overwrite=True,
+    )
 
-    assert os.path.exists('/tmp/L200-comm-20211130-phy-spms.lh5')
+    assert os.path.exists("/tmp/L200-comm-20211130-phy-spms.lh5")
 
 
 def test_build_raw_fc_out_spec(lgnd_test_data):
-    out_file = '/tmp/L200-comm-20211130-phy-spms.lh5'
+    out_file = "/tmp/L200-comm-20211130-phy-spms.lh5"
     out_spec = {
-      'FCEventDecoder': {
-        'spms': {
-          'key_list': [[2, 4]],
-          'out_stream': out_file
-        }
-      }
+        "FCEventDecoder": {"spms": {"key_list": [[2, 4]], "out_stream": out_file}}
     }
 
-    raw.build_raw(in_stream=lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'),
-                  out_spec=out_spec, n_max=10)
+    build_raw(
+        in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
+        out_spec=out_spec,
+        n_max=10,
+        overwrite=True,
+    )
 
     store = LH5Store()
-    lh5_obj, n_rows = store.read_object('/spms', out_file)
+    lh5_obj, n_rows = store.read_object("/spms", out_file)
     assert n_rows == 10
-    assert (lh5_obj['channel'].nda == [2, 3, 4, 2, 3, 4, 2, 3, 4, 2]).all()
+    assert (lh5_obj["channel"].nda == [2, 3, 4, 2, 3, 4, 2, 3, 4, 2]).all()
 
-    raw.build_raw(in_stream=lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'),
-                  out_spec=f'{config_dir}/fc-out-spec.json', n_max=10)
+    build_raw(
+        in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
+        out_spec=f"{config_dir}/fc-out-spec.json",
+        n_max=10,
+        overwrite=True,
+    )
 
 
 def test_build_raw_fc_channelwise_out_spec(lgnd_test_data):
-    out_file = '/tmp/L200-comm-20211130-phy-spms.lh5'
+    out_file = "/tmp/L200-comm-20211130-phy-spms.lh5"
     out_spec = {
-      'FCEventDecoder': {
-        's{key}': {
-          'key_list': [[0, 6]],
-          'out_stream': out_file
+        "FCEventDecoder": {
+            "ch{key}": {
+                "key_list": [[0, 6]],
+                "out_stream": out_file + ":{name}",
+                "out_name": "raw",
+            }
         }
-      }
     }
 
-    raw.build_raw(in_stream=lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'),
-                  out_spec=out_spec)
+    build_raw(
+        in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio"),
+        out_spec=out_spec,
+        overwrite=True,
+    )
 
-    assert ls(out_file) == ['s0', 's1', 's2', 's3', 's4', 's5']
+    assert ls(out_file) == ["ch0", "ch1", "ch2", "ch3", "ch4", "ch5"]
+    assert ls(out_file, "ch0/") == ["ch0/raw"]
+    assert ls(out_file, "ch0/raw/waveform") == ["ch0/raw/waveform"]
 
 
 def test_build_raw_orca(lgnd_test_data):
-    raw.build_raw(in_stream=lgnd_test_data.get_path('orca/fc/L200-comm-20220519-phy-geds.orca'))
+    build_raw(
+        in_stream=lgnd_test_data.get_path("orca/fc/L200-comm-20220519-phy-geds.orca"),
+        overwrite=True,
+    )
 
-    assert lgnd_test_data.get_path('orca/fc/L200-comm-20220519-phy-geds.lh5') != ''
+    assert lgnd_test_data.get_path("orca/fc/L200-comm-20220519-phy-geds.lh5") != ""
 
-    out_file = '/tmp/L200-comm-20220519-phy-geds.lh5'
+    out_file = "/tmp/L200-comm-20220519-phy-geds.lh5"
 
-    raw.build_raw(in_stream=lgnd_test_data.get_path('orca/fc/L200-comm-20220519-phy-geds.orca'),
-                  out_spec=out_file)
+    build_raw(
+        in_stream=lgnd_test_data.get_path("orca/fc/L200-comm-20220519-phy-geds.orca"),
+        out_spec=out_file,
+        overwrite=True,
+    )
 
-    assert os.path.exists('/tmp/L200-comm-20220519-phy-geds.lh5')
+    assert os.path.exists("/tmp/L200-comm-20220519-phy-geds.lh5")
 
 
 def test_build_raw_orca_out_spec(lgnd_test_data):
-    out_file = '/tmp/L200-comm-20220519-phy-geds.lh5'
+    out_file = "/tmp/L200-comm-20220519-phy-geds.lh5"
     out_spec = {
-      'ORFlashCamADCWaveformDecoder': {
-        'geds': {
-          'key_list': [[2, 4]],
-          'out_stream': out_file
+        "ORFlashCamADCWaveformDecoder": {
+            "geds": {"key_list": [[2, 4]], "out_stream": out_file}
         }
-      }
     }
 
-    raw.build_raw(in_stream=lgnd_test_data.get_path('orca/fc/L200-comm-20220519-phy-geds.orca'),
-                  out_spec=out_spec, n_max=10)
+    build_raw(
+        in_stream=lgnd_test_data.get_path("orca/fc/L200-comm-20220519-phy-geds.orca"),
+        out_spec=out_spec,
+        n_max=10,
+        overwrite=True,
+    )
 
     store = LH5Store()
-    lh5_obj, n_rows = store.read_object('/geds', out_file)
+    lh5_obj, n_rows = store.read_object("/geds", out_file)
     assert n_rows == 10
-    assert (lh5_obj['channel'].nda == [2, 3, 4, 2, 3, 4, 2, 3, 4, 2]).all()
+    assert (lh5_obj["channel"].nda == [2, 3, 4, 2, 3, 4, 2, 3, 4, 2]).all()
 
-    raw.build_raw(in_stream=lgnd_test_data.get_path('orca/fc/L200-comm-20220519-phy-geds.orca'),
-                  out_spec=f'{config_dir}/orca-out-spec.json', n_max=10)
+    build_raw(
+        in_stream=lgnd_test_data.get_path("orca/fc/L200-comm-20220519-phy-geds.orca"),
+        out_spec=f"{config_dir}/orca-out-spec.json",
+        n_max=10,
+        overwrite=True,
+    )
 
 
 def test_build_raw_overwrite(lgnd_test_data):
-    with pytest.raises(RuntimeError):
-        raw.build_raw(in_stream=lgnd_test_data.get_path('fcio/L200-comm-20211130-phy-spms.fcio'),
-                      overwrite=False)
+    with pytest.raises(FileExistsError):
+        build_raw(
+            in_stream=lgnd_test_data.get_path("fcio/L200-comm-20211130-phy-spms.fcio")
+        )
diff --git a/tests/raw/test_cli.py b/tests/raw/test_cli.py
index 40eb9c086..6bc218b32 100644
--- a/tests/raw/test_cli.py
+++ b/tests/raw/test_cli.py
@@ -2,19 +2,26 @@
 import subprocess
 from pathlib import Path
 
-config_dir = Path(__file__).parent/'configs'
+config_dir = Path(__file__).parent / "configs"
 
 
 def test_build_raw_cli(lgnd_test_data):
-    subprocess.check_call(['pygama', 'build-raw', '--help'])
-    subprocess.check_call([
-        'pygama', 'build-raw',
-        '--overwrite',
-        '--stream-type', 'ORCA',
-        '--out-spec', f'{config_dir}/orca-out-spec-cli.json',
-        '--max-rows', '10',
-        '--buffer_size', '8192',
-        lgnd_test_data.get_path('orca/fc/L200-comm-20220519-phy-geds.orca')
-    ])
+    subprocess.check_call(["pygama", "build-raw", "--help"])
+    subprocess.check_call(
+        [
+            "pygama",
+            "build-raw",
+            "--overwrite",
+            "--stream-type",
+            "ORCA",
+            "--out-spec",
+            f"{config_dir}/orca-out-spec-cli.json",
+            "--max-rows",
+            "10",
+            "--buffer_size",
+            "8192",
+            lgnd_test_data.get_path("orca/fc/L200-comm-20220519-phy-geds.orca"),
+        ]
+    )
 
-    assert os.path.exists('/tmp/L200-comm-20220519-phy-geds.lh5')
+    assert os.path.exists("/tmp/L200-comm-20220519-phy-geds.lh5")
diff --git a/tests/raw/test_raw_buffer.py b/tests/raw/test_raw_buffer.py
index ed9f676ef..36d15cfed 100644
--- a/tests/raw/test_raw_buffer.py
+++ b/tests/raw/test_raw_buffer.py
@@ -9,15 +9,15 @@ def test_raw_buffer_list():
         {
             "g{key:0>3d}": {
                 "key_list": [[3, 6]],
-                "out_stream": "$DATADIR/{file_key}_geds.lh5"
+                "out_stream": "$DATADIR/{file_key}_geds.lh5",
             }
         },
-        kw_dict={'file_key': 'run0'}
+        kw_dict={"file_key": "run0"},
     )
     assert len(rbl) == 4
-    assert rbl[1].out_stream == '$DATADIR/run0_geds.lh5'
-    assert rbl.get_list_of('out_name') == ['g003', 'g004', 'g005', 'g006']
-    assert rbl.get_list_of('key_list') == [[3], [4], [5], [6]]
+    assert rbl[1].out_stream == "$DATADIR/run0_geds.lh5"
+    assert rbl.get_list_of("out_name") == ["g003", "g004", "g005", "g006"]
+    assert rbl.get_list_of("key_list") == [[3], [4], [5], [6]]
 
     rbl.clear()
     rbl.set_from_json_dict(
@@ -25,13 +25,13 @@ def test_raw_buffer_list():
             "spms": {
                 "key_list": [[3, 6]],
                 "out_stream": "$DATADIR/{file_key}_geds.lh5",
-                "out_name": "testspms"
+                "out_name": "testspms",
             }
         },
-        kw_dict={'file_key': 'run0'}
+        kw_dict={"file_key": "run0"},
     )
-    assert rbl.get_list_of('out_name') == ['testspms']
-    assert rbl.get_list_of('key_list') == [[3, 4, 5, 6]]
+    assert rbl.get_list_of("out_name") == ["testspms"]
+    assert rbl.get_list_of("key_list") == [[3, 4, 5, 6]]
 
 
 def test_raw_buffer_lib_json_load():
@@ -65,7 +65,7 @@ def test_raw_buffer_lib_json_load():
     }
     """
     json_dict = json.loads(rb_json)
-    rblib = prb.RawBufferLibrary(json_dict=json_dict, kw_dict={'file_key': 'run0'})
-    rb_keyed = rblib['FCEventDecoder'].get_keyed_dict()
+    rblib = prb.RawBufferLibrary(json_dict=json_dict, kw_dict={"file_key": "run0"})
+    rb_keyed = rblib["FCEventDecoder"].get_keyed_dict()
     name = rb_keyed[41].out_name
-    assert name == 'g041'
+    assert name == "g041"
diff --git a/tests/vis/configs/hpge-dsp-config.json b/tests/vis/configs/hpge-dsp-config.json
new file mode 100644
index 000000000..797044bd4
--- /dev/null
+++ b/tests/vis/configs/hpge-dsp-config.json
@@ -0,0 +1,29 @@
+{
+  "processors": {
+    "wf_blsub": {
+      "function": "bl_subtract",
+      "module": "pygama.dsp.processors",
+      "args": ["waveform", "baseline", "wf_blsub"],
+      "unit": "ADC"
+    },
+    "wf_pz": {
+      "function": "pole_zero",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", "150*us", "wf_pz"],
+      "unit": "ADC"
+    },
+    "wf_trap": {
+      "function": "trap_norm",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "10*us", "3.008*us", "wf_trap"],
+      "unit": "ADC"
+    },
+    "trapEmax": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["wf_trap", 1, "trapEmax"],
+      "kwargs": { "signature": "(n),()->()", "types": ["fi->f"] },
+      "unit": "ADC"
+    }
+  }
+}
diff --git a/tests/vis/test_waveform_browser.py b/tests/vis/test_waveform_browser.py
new file mode 100644
index 000000000..1ccd57aae
--- /dev/null
+++ b/tests/vis/test_waveform_browser.py
@@ -0,0 +1,24 @@
+from pathlib import Path
+
+from pygama.vis import WaveformBrowser
+
+config_dir = Path(__file__).parent / "configs"
+
+
+def test_waveform_browser(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",
+        n_drawn=2,
+        x_lim=("20*us", "60*us"),
+        x_unit="us",
+    )
+
+    wb.draw_next()
+    wb.draw_entry(24)
+    wb.draw_entry((2, 24))