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

Issue:
Optimizer changes
Changes optimizer to use db system to interact with processing chain adds more flexibility when defining companions and allows mutliple processors to be varied together. Also added multiprocessing and ability to run multiple grids at same time. Modified build processing chain to search for all instances of db. in args.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
deleted file mode 100644
index 6195c2062..000000000
--- a/.github/workflows/docs.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-name: pygama
-
-on:
-  push:
-    branches:
-      - master
-      - dev
-      - 'releases/**'
-    tags: '*'
-  release:
-
-jobs:
-
-  deploy-docs:
-    name: Deploy documentation on legend-exp.github.io/pygama
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v2
-      - uses: actions/setup-python@v2
-        with:
-          python-version: 3.8
-      - name: Build with Sphinx
-        run: |
-          .github/workflows/get-dependencies.sh
-          pip install .
-          pip install sphinx sphinx-rtd-theme
-          cd docs
-          make clean
-          make html
-      - name: Deploy to GitHub pages
-        uses: JamesIves/github-pages-deploy-action@4.1.0
-        with:
-          branch: gh-pages
-          folder: docs/build/html
diff --git a/.github/workflows/get-dependencies.sh b/.github/workflows/get-dependencies.sh
index ea3594af4..3d60d99d8 100755
--- a/.github/workflows/get-dependencies.sh
+++ b/.github/workflows/get-dependencies.sh
@@ -9,4 +9,4 @@ else
     sudo ln -s /usr/bin/llvm-config-8 /usr/bin/llvm-config
 fi
 
-python -m pip install --upgrade pip numpy
+python -m pip install --upgrade pip wheel setuptools numpy GitPython pytest
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 3f19a5555..f449546bd 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -3,10 +3,11 @@ name: pygama
 on:
   push:
     branches:
+      - main
       - master
       - dev
+      - refactor
       - 'releases/**'
-    tags: '*'
   pull_request:
   release:
 
@@ -14,11 +15,12 @@ jobs:
 
   build-and-test:
     name: Test pygama with Python
-    runs-on: ubuntu-latest
+    runs-on: ${{ matrix.os }}
     strategy:
       fail-fast: false
       matrix:
-        python-version: [3.7, 3.8]
+        python-version: ['3.7', '3.8', '3.9', '3.10']
+        os: [ubuntu-latest, macOS-latest]
 
     steps:
     - uses: actions/checkout@v2
@@ -26,20 +28,53 @@ jobs:
       uses: actions/setup-python@v2
       with:
         python-version: ${{ matrix.python-version }}
-    - name: Run unit tests
+    - name: Get dependencies and install the package
       run: |
         .github/workflows/get-dependencies.sh
-        pip install .
-    # - name: Test
-    #   run: |
-    #     pytest
+        python -m pip install .
+    - name: Run unit tests
+      run: pytest test
 
   test-in-legend-container:
-    name: Test pygama in LEGEND container (CentOS Linux)
+    name: Test pygama in LEGEND container
     runs-on: ubuntu-latest
     container: docker://legendexp/legend-base:latest
 
     steps:
     - uses: actions/checkout@v2
-    - name: Run unit tests
-      run: pip install .
+    - name: Install and run unit tests
+      run: |
+        pip install .
+        pytest test
+
+  deploy-docs:
+    name: Deploy documentation on legend-exp.github.io/pygama
+    needs: build-and-test
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - uses: actions/setup-python@v2
+        with:
+          python-version: 3.8
+      - name: Setup build environment
+        run: |
+          git fetch --no-tags --prune --no-recurse-submodules --depth=1 origin
+          .github/workflows/get-dependencies.sh
+          pip install sphinx sphinx-rtd-theme sphinx-multiversion
+      - name: Build docs for current ref
+        run: |
+          cd docs
+          make clean
+          make this
+      - name: Build docs for selected versions (GitHub pages)
+        run: |
+          cd docs
+          make clean
+          make info
+          make
+      - if: ${{ github.event_name == 'push' }}
+        name: Deploy to GitHub pages
+        uses: JamesIves/github-pages-deploy-action@v4.2.2
+        with:
+          branch: gh-pages
+          folder: docs/build/allver/html
diff --git a/README.md b/README.md
index 8ae4c4924..b89303532 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,17 @@
 # pygama
-Python based package for data processing and analysis
 
-## installation
-Install on local systems with:
-```bash
-$ git clone [url]
-$ pip install -e pygama
-```
-Installation at NERSC:
-```
-pip install -e pygama --user
-```
-Uninstall: `pip uninstall pygama`
+![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/legend-exp/pygama?logo=git)
+![Documentation (master)](https://img.shields.io/badge/documentation-online-purple?logo=readthedocs&link=https%3A%2F%2Flegend-exp.github.io%2Fpygama)
+![GitHub Workflow Status (master)](https://img.shields.io/github/workflow/status/legend-exp/pygama/pygama/master?label=master%20branch&logo=github)
+![GitHub Workflow Status (dev)](https://img.shields.io/github/workflow/status/legend-exp/pygama/pygama/dev?label=dev%20branch&logo=github)
+![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)
 
-To run pygama at NERSC (and set up JupyterHub), we have additional instructions [at this link](https://github.com/legend-exp/legend/wiki/Computing-Resources-at-NERSC#configuring-jupyter--nersc).  
+*pygama* is a Python package for:
 
-## overview
-
-`pygama` is a python package for:
-* converting physics data acquisition system output to "lh5"-format hdf5 files
-* performing bulk digital signal processing on time-series data
-* optimizing those DSP routines and tuning associated analysis parameters
+* converting physics data acquisition system output to [LH5-format](https://github.com/legend-exp/legend-data-format-specs) HDF5 files
+* performing bulk digital signal processing (DSP) on time-series data
+* optimizing DSP routines and tuning associated analysis parameters
 * generating and selecting high-level event data for further analysis
 
-The basic steps for a typical analysis are:
-
-1. Convert DAQ output to "raw" lh5 format using [`build_raw`](pygama/raw)
-2. Browse the [LEGEND data objects](pygama/lgdo) in the lh5 files to verify its integrity
-3. Optimize the DSP parameters using `dsp_optimize.py` in [pargen](pygama/pargen)
-4. Run [`build_dsp`](pygama/dsp) on the raw files to generate "dsp" lh5 output
-5. Optimize the hit parameters using routines in [pargen](pygama/pargen)
-4. Run `build_hit` (or create your own version) to generate hit files from the dsp data
-5. Run `hit_to_evt` to generate files with event structures
-6. Concatenate / join / filter evt and raw-dsp-hit data to extract the fields you need for higher-level analysis
-
-## testing
-
-pygama testing uses the [pytest](https://pytest.org) framework following the [numpy testing guidelines](https://numpy.org/doc/stable/reference/testing.html#testing-guidelines). To run tests, just enter `pytest` at the command line.
diff --git a/attic/dsp_config.json b/attic/dsp_config.json
index 74f023462..d475b5de2 100644
--- a/attic/dsp_config.json
+++ b/attic/dsp_config.json
@@ -7,21 +7,18 @@
       "function": "linear_slope_fit",
       "module": "pygama.dsp.processors",
       "args" : ["waveform[0: 1000]", "bl_mean","bl_sig", "bl_slope","bl_intercept"],
-      "prereqs": ["waveform"],
       "unit": ["ADC","ADC","ADC","ADC"]
     },
     "wf_blsub":{
       "function": "subtract",
       "module": "numpy",
       "args": ["waveform", "bl_mean", "wf_blsub"],
-      "prereqs": ["waveform", "bl_mean"],
       "unit": "ADC"
     },
     "wf_pz": {
       "function": "pole_zero",
       "module": "pygama.dsp.processors",
       "args": ["wf_blsub", "db.pz_const", "wf_pz"],
-      "prereqs": ["wf_blsub"],
       "unit": "ADC",
       "defaults": { "db.pz_const":"72*us" }
     },
@@ -29,138 +26,119 @@
       "function": "trap_norm",
       "module": "pygama.dsp.processors",
       "args": ["wf_pz", "10*us", "5*us", "wf_trap"],
-      "prereqs": ["wf_pz"],
       "unit": "ADC"
     },
     "wf_triangle": {
       "function": "trap_norm",
       "module": "pygama.dsp.processors",
       "args": ["wf_pz", "100*ns", "10*ns", "wf_triangle"],
-      "prereqs": ["wf_pz"],
       "unit": "ADC"
     },
     "wf_atrap": {
       "function": "asym_trap_filter",
       "module": "pygama.dsp.processors",
       "args": ["wf_pz", "0.05*us", "2*us", "3*us", "wf_atrap"],
-      "prereqs": ["wf_pz"],
       "unit": "ADC"
     },
     "tp_min, tp_max, wf_min, wf_max":{
       "function": "min_max",
       "module": "pygama.dsp.processors",
       "args": ["waveform", "tp_min", "tp_max", "wf_min", "wf_max"],
-      "unit": ["ns","ns","ADC", "ADC"],
-      "prereqs":["waveform"]
+      "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",
-      "prereqs": ["wf_atrap", "tp_max"]
+      "unit": "ns"
      },
     "tp_95": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
       "args": ["wf_blsub", "0.95*tp_max", "tp_max", 0,"tp_95"],
-      "unit": "ns",
-      "prereqs": ["wf_blsub", "tp_max"]
+      "unit": "ns"
      },
     "tp_80": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
       "args": ["wf_blsub", "0.8*tp_max", "tp_max", 0,"tp_80"],
-      "unit": "ns",
-      "prereqs": ["wf_blsub", "tp_max"]
+      "unit": "ns"
      },
     "tp_50": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
       "args": ["wf_blsub", "0.50*tp_max", "tp_max", 0,"tp_50"],
-      "unit": "ns",
-      "prereqs": ["wf_blsub", "tp_max"]
+      "unit": "ns"
      },
     "tp_20": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
       "args": ["wf_blsub", "0.2*tp_max", "tp_max", 0,"tp_20"],
-      "unit": "ns",
-      "prereqs": ["wf_blsub", "tp_max"]
+      "unit": "ns"
      },
     "tp_05": {
       "function": "time_point_thresh",
       "module": "pygama.dsp.processors",
       "args": ["wf_blsub", "0.05*tp_max", "tp_max", 0,"tp_05"],
-      "unit": "ns",
-      "prereqs": ["wf_blsub", "tp_max"]
+      "unit": "ns"
      },
      "trapEftp": {
        "function": "fixed_time_pickoff",
        "module": "pygama.dsp.processors",
        "args": ["wf_trap", "tp_0+(5*us+9*us)", "trapEftp"],
-       "unit": "ADC",
-       "prereqs": ["wf_trap", "tp_0"]
+       "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",
-       "prereqs": ["wf_pz", "tp_0"]
+       "args": ["wf_pz", 200, 1000, "tp_0+round(1.504*us)", "dcr_raw"],
+       "unit": "ADC"
      },
      "dcr": {
        "function": "divide",
        "module": "numpy",
        "args": ["dcr_raw", "trapEftp", "dcr"],
-       "unit": "ADC",
-       "prereqs": ["dcr_raw", "trapEftp"]
+       "unit": "ADC"
      },
     "trapEmax": {
       "function": "amax",
       "module": "numpy",
       "args": ["wf_trap", 1, "trapEmax"],
       "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
-      "unit": "ADC",
-      "prereqs": ["wf_trap"]
+      "unit": "ADC"
     },
     "triE": {
       "function": "amax",
       "module": "numpy",
       "args": ["wf_triangle", 1, "triE"],
       "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
-      "unit": "ADC",
-      "prereqs": ["wf_triangle"]
+      "unit": "ADC"
     },
     "curr10": {
       "comment": "3rd argument allocates memory for a derivative of type float",
       "function": "avg_current",
       "module": "pygama.dsp.processors",
       "args": ["wf_pz", 10, "curr10(len(wf_pz)-10, f)"],
-      "unit": "ADC/sample",
-      "prereqs": ["wf_pz"]
+      "unit": "ADC/sample"
     },
     "A_10": {
       "function": "amax",
       "module": "numpy",
       "args": ["curr10", 1, "A_10"],
       "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
-      "unit": "ADC",
-      "prereqs": ["curr10"]
+      "unit": "ADC"
     },
     "AoE": {
       "function": "divide",
       "module": "numpy",
       "args":["A_10", "trapEmax", "AoE"],
-      "unit": "1/sample",
-      "prereqs": ["A_10", "trapEmax"]
+      "unit": "1/sample"
     },
     "zac_wf": {
       "function": "zac_filter",
       "module": "pygama.dsp.processors",
       "args": ["waveform", "zac_wf(101, f)"],
       "init_args": ["len(waveform)", "3.5*us", "2.5*us", "72*us"],
-      "prereqs": ["waveform"],
       "unit": "ADC"
     },
     "zacE": {
@@ -168,15 +146,13 @@
       "module": "numpy",
       "args": ["zac_wf", 1, "zacE"],
       "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
-      "unit": "ADC",
-      "prereqs": ["zac_wf"]
+      "unit": "ADC"
     },
     "cusp_wf": {
       "function": "cusp_filter",
       "module": "pygama.dsp.processors",
       "args": ["waveform", "cusp_wf(101, f)"],
       "init_args": ["len(waveform)", "10*us", "1*us", "200*us"],
-      "prereqs": ["waveform"],
       "unit": "ADC"
     },
     "cuspE": {
@@ -184,39 +160,34 @@
       "module": "numpy",
       "args": ["cusp_wf", 1, "cuspE"],
       "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
-      "unit": "ADC",
-      "prereqs": ["cusp_wf"]
+      "unit": "ADC"
     },
     "curr": {
       "function": "avg_current",
       "module": "pygama.dsp.processors",
       "args": ["wf_pz", 1, "curr(len(wf_pz)-1, f)"],
-      "unit": "ADC/sample",
-      "prereqs": ["wf_pz"]
+      "unit": "ADC/sample"
     },
     "curr_psd": {
       "function": "psd",
       "module": "pygama.dsp.processors",
       "args": ["curr", "curr_psd"],
       "init_args": ["curr", "curr_psd(len(curr)//2+1, f)"],
-      "unit": "",
-      "prereqs": ["curr"]
+      "unit": ""
     },
     "hf_max": {
       "function": "amax",
       "module": "numpy",
       "args": ["curr_psd[round(10*mhz*len(curr)):]", 1, "hf_max"],
       "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
-      "unit": "",
-      "prereqs": ["curr_psd"]
+      "unit": ""
     },
     "power_spect": {
       "function": "psd",
       "module": "pygama.dsp.processors",
       "args": ["wf_blsub", "power_spect"],
       "init_args": ["wf_blsub", "power_spect(len(wf_blsub)//2+1, f)"],
-      "unit": "MHz",
-      "prereqs": ["wf_blsub"]
+      "unit": "MHz"
     }
     
   }
diff --git a/attic/pygama/raw/stream_llama.py b/attic/pygama/raw/stream_llama.py
index 1254c33e6..cd92a03ca 100644
--- a/attic/pygama/raw/stream_llama.py
+++ b/attic/pygama/raw/stream_llama.py
@@ -1,13 +1,14 @@
 import sys
-import numpy as np  
+import numpy as np
 import pandas as pd
 from pprint import pprint
 
 from .io_base import DataTaker
 #from .waveform import Waveform
+from ..utils import tqdm_range, update_progress
 
 class llama_3316:
-    """ 
+    """
     A parser file for the sis3316 able to decode header and events.
     The inputs are files produced by the llamaDAQ sis3316 readout program
     magic bytes: "LArU"
@@ -17,7 +18,7 @@ def __init__(self, file_binary, verbosity=0):
         self.verbose = verbosity
         flag = self.parse_fileheader()
         # chConf = self.parse_channelConfigs()
-        
+
         #idx, nrx = self.__read_chunk_header()
         #print("head1: ID: {}, nr: {}".format(idx, nrx))
         #flag = True
@@ -25,7 +26,7 @@ def __init__(self, file_binary, verbosity=0):
         #    fi, ci, da = self.read_next_event(chConf)
         #    if da is None:
         #        flag = False
-        
+
     def parse_fileheader(self):
         """
         parses the fileheader of a SIS3316 file.
@@ -40,7 +41,7 @@ def parse_fileheader(self):
         header = self.f_in.read(16)
         evt_data_32 = np.fromstring(header, dtype=np.uint32)
         evt_data_16 = np.fromstring(header, dtype=np.uint16)
-        
+
         #line0: magic bytes
         magic = evt_data_32[0]
         #print(hex(magic))
@@ -50,64 +51,64 @@ def parse_fileheader(self):
         else:
             print ("ERROR: Magic bytes not matching for SIS3316 file!")
             return False
-        
+
         self.version_major = evt_data_16[4]
         self.version_minor = evt_data_16[3]
         self.version_patch = evt_data_16[2]
         self.length_econf = evt_data_16[5]
         self.number_chOpen = evt_data_32[3]
-        
+
         if self.verbose > 0:
             print ("File version: {}.{}.{}".format(self.version_major, self.version_minor, self.version_patch))
             print ("{} channels open, each config {} bytes long".format(self.number_chOpen, self.length_econf))
-            
+
         return True
-       
-        
+
+
     def parse_channelConfigs(self):
         """
         Reads the metadata from the beginning of the file (the "channel configuration" part, directly after the file header).
         Creates a dictionary of the metadata for each FADC/channel combination, which is returned
-        
+
         structure of channelConfigs:
         FADCindex      channelIndex
         A ------------- x ----------- metadata for FADC A channel x
                       | y ----------- metadata for FADC A channel y
                       | z ----------- metadata for FADC A channel z
-                      
+
         B ------------- k ----------- metadata for FADC B channel k
                       | l ----------- metadata for FADC B channel l
         ...
-                      
+
         """
         self.f_in.seek(16)    #should be after file header anyhow, but re-set if not
-        
+
         channelConfigs = {}
-        
+
         for i in range(0, self.number_chOpen):
             if self.verbose > 1:
                 print("reading in channel config {}".format(i))
-                
+
             channel = self.f_in.read(68)
             ch_dpf = channel[16:32]
             evt_data_32 = np.fromstring(channel, dtype=np.uint32)
             evt_data_dpf = np.fromstring(ch_dpf, dtype=np.float64)
-            
+
             fadcIndex = evt_data_32[0]
             channelIndex = evt_data_32[1]
-            
+
             if fadcIndex in channelConfigs:
                 #print("pre-existing fadc")
                 pass
             else:
                 #print("new fadc #{}".format(fadcIndex))
                 channelConfigs[fadcIndex] = {}
-    
+
             if channelIndex in channelConfigs[fadcIndex]:
                 print("ERROR: Duplicate channel configuration in file: FADCID: {}, ChannelID: {}".format(fadcIndex, channelIndex))
             else:
                 channelConfigs[fadcIndex][channelIndex] = {}
-                
+
             channelConfigs[fadcIndex][channelIndex]["14BitFlag"] = evt_data_32[2] & 0x00000001
             if evt_data_32[2] & 0x00000002 == 0:
                 print("WARNING: Channel in configuration marked as non-open!")
@@ -123,20 +124,20 @@ def parse_channelConfigs(self):
             channelConfigs[fadcIndex][channelIndex]["Accum2Offset"] = evt_data_32[14]
             channelConfigs[fadcIndex][channelIndex]["MAW3Offset"] = evt_data_32[15]
             channelConfigs[fadcIndex][channelIndex]["SampleOffset"] = evt_data_32[16]
-            
-            
-        
+
+
+
         if self.verbose > 0:
             pprint(channelConfigs)
-            
+
         #self.chConfigs = channelConfigs
-        
+
         self.currentEventIndex=-1   #points to header of next chunk, not to event
         self.currentChunkIndex=0    #points to first chunk of file
-            
+
         return channelConfigs
-        
-        
+
+
     def __read_chunk_header(self):
         """
         reads the header of the chunk
@@ -147,58 +148,58 @@ def __read_chunk_header(self):
         header = self.f_in.read(8)
         if len(header) < 8:
             raise BinaryReadException(8, len(header))
-        
+
         header_data_32 = np.fromstring(header, dtype=np.uint32)
-        
+
         self.currentEventIndex=0   #points to first event of chunk
- 
+
         if header_data_32[1] == 0:
             if self.verbose > 1:
                 print("Warning: having a chunk with 0 events")
-        
+
         return header_data_32[0], header_data_32[1]
-    
-    
+
+
     def __read_next_event(self, fadcID, channelConfigs, nrEventsPerChunk):
         """
         reads a chunk of data containing event data (event header and samples)
         file pointer has to be on beginning of event, NOT on chunk header
         Returns the channelID of the Event, as well as the chunk of data
         """
-        
+
         if self.currentEventIndex == -1:
             print("ERROR: pointing at chunk header!")
             return None
-            
+
         if self.verbose > 1:
             print("Reading chunk #{} event #{}".format(self.currentChunkIndex, self.currentEventIndex))
-        
+
         position = self.f_in.tell()     #save position of the event header's 1st byte
         data1 = self.f_in.read(4)       #read the first (32 bit) word of the event's header: channelID & format bits
         if len(data1) < 4:
             raise BinaryReadException(4, len(data1))
         self.f_in.seek(position)        #go back to 1st position of event header
-        
+
         header_data_32 = np.fromstring(data1, dtype=np.uint32)
         channelID = (header_data_32[0] >> 4) & 0x00000fff
         if self.verbose > 1:
             print("Event is from FADC #{}, channel #{}".format(fadcID, channelID))
         eventLength8 = channelConfigs[fadcID][channelID]["EventLength"]
         eventLength8 *= 4 #EventLength is in 32 bit
-        
+
         data = self.f_in.read(eventLength8)
         if len(data) < eventLength8:
             raise BinaryReadException(eventLength8, len(data))
-    
+
         self.currentEventIndex += 1     #move to next event
         if(self.currentEventIndex == nrEventsPerChunk): #if this event was the last of the chunk
             self.currentEventIndex = -1     #points to header of next chunk, not to event
             self.currentChunkIndex += 1     #points to next chunk of file
-    
-    
+
+
         return channelID, data
-    
-    
+
+
     def read_next_event(self, channelConfigs):
         """
         This should be the main method to call when parsing the file for events.
@@ -208,9 +209,9 @@ def read_next_event(self, channelConfigs):
         when appropriate.
         returns FADCindex, channelIndex, binaryEventData
         """
-        
+
         # have to extract channel index from binary, since we need the length of the event, which can change between channels
-        
+
         if self.currentEventIndex == -1:     #points to header of next chunk, not to event
             self.currentChunkSize = 0
             try:
@@ -219,32 +220,32 @@ def read_next_event(self, channelConfigs):
             except BinaryReadException as e:
                 #print("  No more data...\n")
                 return -1,-1,None
-                
+
         try:
             channelID, binary_data = self.__read_next_event(self.currentFADC, channelConfigs, self.currentChunkSize)
         except BinaryReadException as e:
             #print("  No more data...\n")
             return -1,-1,None
-            
+
         return self.currentFADC, channelID, binary_data
-        
-    
-    
+
+
+
 class BinaryReadException(Exception):
 
     def __init__(self, requestedNrOfBytes, gotNrOfBytes):
         self.reqNOB = requestedNrOfBytes
         self.gotNOB = gotNrOfBytes
-    
+
     def printMessage(self):
         print("Exception: tried to read {} bytes, got {} bytes".format(self.reqNOB, self.gotNOB))
-    
+
 
 
 class LLAMAStruck3316(DataTaker):
-    """ 
+    """
     decode Struck 3316 digitizer data
-    
+
     TODO:
     handle per-channel data (gain, ...)
     most metadata of Struck header (energy, ...)
@@ -281,7 +282,7 @@ def __init__(self, metadata=None, *args, **kwargs):
 
         self.config_names = []  #TODO at some point we want the metainfo here
         self.file_config = {}
-        if metadata is not None: 
+        if metadata is not None:
             self.file_config = self.readMetadata(metadata)
             print("We have {} adcs and {} samples per WF.".format(self.file_config["nadcs"],self.file_config["nsamples"]))
 
@@ -289,7 +290,7 @@ def __init__(self, metadata=None, *args, **kwargs):
 
         # self.event_header_length = 1 #?
         self.sample_period = 0  # ns, I will set this later, according to header info
-        self.gain = 0           
+        self.gain = 0
         self.h5_format = "table"	#was table
         #self.n_blsamp = 2000
         self.ievt = 0       #event number
@@ -322,7 +323,7 @@ def readMetadata(self, meta):
         configs["nadcs"] = totChan
         configs["nsamples"] = nsamples
         return configs
-        
+
     def initialize(self, sample_period, gain):
         """
         sets certain global values from a run, like:
@@ -332,22 +333,22 @@ def initialize(self, sample_period, gain):
         """
         self.sample_period = sample_period
         self.gain = gain
-        
-        
-    def decode_event(self, event_data_bytes, packet_id, header_dict, fadcIndex, 
+
+
+    def decode_event(self, event_data_bytes, packet_id, header_dict, fadcIndex,
                      channelIndex, verbose=False):
         """
         see the llamaDAQ documentation for data word diagrams
         """
-        
+
         if self.sample_period == 0:
             print("ERROR: Sample period not set; use initialize() before using decode_event() on SIS3316Decoder")
             raise Exception ("Sample period not set")
-        
+
         # parse the raw event data into numpy arrays of 16 and 32 bit ints
         evt_data_32 = np.fromstring(event_data_bytes, dtype=np.uint32)
         evt_data_16 = np.fromstring(event_data_bytes, dtype=np.uint16)
-        
+
         # e sti gran binaries non ce li metti
         timestamp = ((evt_data_32[0] & 0xffff0000) << 16) + evt_data_32[1]
         format_bits = (evt_data_32[0]) & 0x0000000f
@@ -365,7 +366,7 @@ def decode_event(self, event_data_bytes, packet_id, header_dict, fadcIndex,
             offset += 7
         else:
             peakhigh_value = 0
-            peakhigh_index = 0  
+            peakhigh_index = 0
             information = 0
             accumulator1 = accumulator2 = accumulator3 = accumulator4 = accumulator5 = accumulator6 = 0
             pass
@@ -395,8 +396,8 @@ def decode_event(self, event_data_bytes, packet_id, header_dict, fadcIndex,
         offset += 1 #now the offset points to the wf data
         fadcID = fadcIndex
         channel = channelIndex
-        
-        
+
+
         # compute expected and actual array dimensions
         wf_length16 = 2 * wf_length_32
         header_length16 = offset * 2
@@ -525,13 +526,21 @@ def toFile(digitizer, filename_raw, rowID, verbose):
         digitizer.save_to_pytables(filename_mod, verbose)
 
 
+    n_entries = 0
+    unit = "B"
+    if n_max < np.inf and n_max > 0:
+        n_entries = n_max
+        unit = "id"
+    else:
+        n_entries = file_size
+    progress_bar = tqdm_range(0, int(n_entries), text="Processing", verbose=verbose, unit=unit)
+    file_position = 0
+
+
     # start scanning
     while (packet_id < n_max and f_in.tell() < file_size):
         packet_id += 1
 
-        if verbose and packet_id % 1000 == 0:
-            update_progress(float(f_in.tell()) / file_size)
-
         # write periodically to the output file instead of writing all at once
         if packet_id % ROW_LIMIT == 0:
             for d in decoders:
@@ -550,6 +559,15 @@ def toFile(digitizer, filename_raw, rowID, verbose):
         # sends data to the pandas dataframe
         decoder.decode_event(event_data, packet_id, header_dict, fadcID, channelID)
 
+        if verbose:
+            update_len = 0
+            if n_max < np.inf and n_max > 0:
+                update_len = 1
+            else:
+                update_len = f_in.tell() - file_position
+                file_position = f_in.tell()
+            update_progress(progress_bar, update_len)
+
     print("done.  last packet ID:", packet_id)
     f_in.close()
 
@@ -557,9 +575,6 @@ def toFile(digitizer, filename_raw, rowID, verbose):
     for d in decoders:
         d.save_to_lh5(raw_filename)
 
-    if verbose:
-        update_progress(1)
-
     if len(unrecognized_data_ids) > 0:
         print("WARNING, Found the following unknown data IDs:")
         for id in unrecognized_data_ids:
@@ -572,6 +587,3 @@ def toFile(digitizer, filename_raw, rowID, verbose):
     with pd.HDFStore(raw_filename,'r') as store:
         print(store.keys())
     #    # print(store.info())
-
-
-
diff --git a/clear_cache.sh b/clear_cache.sh
deleted file mode 100755
index d2a6fd3d8..000000000
--- a/clear_cache.sh
+++ /dev/null
@@ -1,3 +0,0 @@
-echo "cleaning pygama..."
-find . -name __pycache__ | xargs rm -rf
-echo "done"
diff --git a/docs/Makefile b/docs/Makefile
index d0c3cbf10..62e24b85c 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -1,20 +1,18 @@
-# Minimal makefile for Sphinx documentation
-#
-
-# You can set these variables from the command line, and also
-# from the environment for the first two.
-SPHINXOPTS    ?=
 SPHINXBUILD   ?= sphinx-build
+SPHINXMULTI   ?= sphinx-multiversion
 SOURCEDIR     = source
+BUILDDIRALL   = build/allver/html
 BUILDDIR      = build
 
-# Put it first so that "make" without argument is like "make help".
-help:
-	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+all: this
+	@$(SPHINXMULTI) "$(SOURCEDIR)" "$(BUILDDIRALL)"
+	cp source/_assets/redirect.html "$(BUILDDIRALL)/index.html"
+
+this:
+	@$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
 
-.PHONY: help Makefile
+info:
+	@$(SPHINXMULTI) --dump-metadata "$(SOURCEDIR)" "$(BUILDDIRALL)"
 
-# Catch-all target: route all unknown targets to Sphinx using the new
-# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
-%: Makefile
-	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+clean:
+	rm -rf "$(BUILDDIR)"
diff --git a/docs/README.md b/docs/README.md
index 95e6da369..deaa054c4 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,10 +1,10 @@
-### regenerating sphinx documentation
+### Documentation
 ```
+$ git fetch origin
 $ make clean
-$ make html
+$ make
 $ open build/html/pygama.html
 ```
-regenerate the .rst files:
+regenerate the `.rst` files:
 ```
 $ sphinx-apidoc -f -o ./source ../pygama/
-```
\ No newline at end of file
diff --git a/docs/source/_assets/redirect.html b/docs/source/_assets/redirect.html
new file mode 100644
index 000000000..42c049822
--- /dev/null
+++ b/docs/source/_assets/redirect.html
@@ -0,0 +1,9 @@
+<!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
new file mode 100644
index 000000000..9bd28131d
--- /dev/null
+++ b/docs/source/_templates/layout.html
@@ -0,0 +1,32 @@
+{% 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 b8886ac25..ee2bece0e 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -19,11 +19,6 @@
 
 project = 'pygama'
 copyright = '2020, LEGEND Collaboration'
-author = 'C. Wiseman'
-
-# The full version, including alpha/beta/rc tags
-release = 'v0.6'
-
 
 # -- General configuration ---------------------------------------------------
 
@@ -39,6 +34,7 @@
     'sphinx.ext.viewcode',
     'sphinx.ext.napoleon',
     'sphinx_rtd_theme',
+    'sphinx_multiversion'
 ]
 
 # The suffix(es) of source filenames.
@@ -99,3 +95,30 @@
 
 # If true, `todo` and `todoList` produce output, else they produce nothing.
 todo_include_todos = True
+
+# -- Options for 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/...`
+
+# Whitelist pattern for branches
+smv_branch_whitelist = r'^(main|refactor|releases/.*)$'
+
+# Whitelist pattern for tags
+smv_tag_whitelist = '^$'
+
+# Pattern for released versions
+smv_released_pattern = '^$'
+
+# The right way to find all docs versions is to look for matching branches on
+# the default remote
+
+# Whitelist pattern for remotes
+smv_remote_whitelist = r'^origin$'
+
+# Format for versioned output directories inside the build directory
+smv_outputdir_format = '{ref.name}'
+
+# Determines whether remote or local git branches/tags are preferred if their output dirs conflict
+smv_prefer_remote_refs = False
diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst
new file mode 100644
index 000000000..2115a0490
--- /dev/null
+++ b/docs/source/contributing.rst
@@ -0,0 +1,65 @@
+Contribution guidelines
+=======================
+
+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.
+
+Coding style
+------------
+
+*In progress...*
+
+Testing
+-------
+
+* The *pygama* test suite is available below ``test/``
+* Open source best practices are adopted. In particular:
+
+  * Tests are provided for each class/method separately (*micro-testing*) e.g.
+    (for each single DSP processor)
+  * High-level tests of realistic applications are provided (e.g. full
+    production of a real data sample)
+
+* Unit tests are automatically run for every push event and pull request to
+  the remote Git repository on a build server (currently GitHub actions)
+* Every pull request must pass all tests before being approved for merging
+* Pull request authors are required to provide tests with sufficient coverage
+  for every proposed change or addition. If necessary, global tests (e.g. tests
+  of full production chain) should be updated.
+
+*In progress...*
+
+Versioning
+----------
+
+* `Semantic versioning <https://semver.org>`_ is adopted. The version string
+  uses the ``MAJOR.MINOR.PATCH`` format.
+* The version string is manually specified in ``pygama/version.py``. If needed
+  elsewhere in the source code (e.g. in ``setup.py``), must be read in from here.
+* To release a new minor or major version, the following procedure should be
+  followed:
+
+  * The *pygama* version is updated in ``pygama/version.py``
+  * A new branch with name ``releases/vMAJOR.MINOR`` (note the ``v``) containing
+    the code at the intended stage is created
+  * The commit is tagged with a descriptive message: ``git tag vMAJOR.MINOR.0 -m 'short descriptive message here'`` (note the ``v``)
+  * Changes are pushed to the remote: ``git push --tags origin releases/vMAJOR.MINOR``
+
+* To release a new patch version, the following procedure should be followed:
+
+  * The *pygama* version is updated in ``pygama/version.py``
+  * A commit with the patch is created on the relevant release branch
+    ``releases/vMAJOR.MINOR``
+  * The commit is tagged: ``git tag vMAJOR.MINOR.PATCH`` (note the ``v``)
+  * Changes are pushed to the remote: ``git push --tags origin releases/vMAJOR.MINOR``
+
+Documentation
+-------------
+
+* Documentation should be exclusively available on the package website
+  https://legend-exp.github.io/pygama. No READMEs, GitHub/LEGEND wiki pages
+  should be written.
+* Jupyter notebooks should be added to the Git repository below ``tutorials/``.
+* Pull request authors are required to provide sufficient documentation (mainly
+  as *docstrings*) for every proposed change or addition.
diff --git a/docs/source/index.rst b/docs/source/index.rst
index b61881112..71e5696fc 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -17,9 +17,10 @@ Package index
    :maxdepth: 4
 
    install
+   contributing
+   tutorials
    Package API reference <pygama>
 
-
 Indices and tables
 ==================
 
diff --git a/docs/source/install.rst b/docs/source/install.rst
index 5886e9ced..be2e89883 100644
--- a/docs/source/install.rst
+++ b/docs/source/install.rst
@@ -1,7 +1,7 @@
 Installing pygama
 =================
 
-Install on local systems with: ::
+Install on local systems with [#f1]_: ::
 
     > git clone https://github.com/legend-exp/pygama
     > pip install -e pygama
@@ -18,3 +18,5 @@ To uninstall pygama run: ::
 
 To run pygama at NERSC (and set up JupyterHub), we have additional instructions
 `at this link <https://github.com/legend-exp/legend/wiki/Computing-Resources-at-NERSC#configuring-jupyter--nersc>`_.
+
+.. [#f1] Compatible with python 3.7 and python 3.8
diff --git a/docs/source/pygama.dsp.rst b/docs/source/pygama.dsp.rst
index 6eca05001..f3c5fa71d 100644
--- a/docs/source/pygama.dsp.rst
+++ b/docs/source/pygama.dsp.rst
@@ -36,6 +36,14 @@ pygama.dsp.dsp\_optimize module
    :undoc-members:
    :show-inheritance:
 
+pygama.dsp.errors module
+------------------------
+
+.. automodule:: pygama.dsp.errors
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 pygama.dsp.processors module
 ----------------------------
 
diff --git a/docs/source/pygama.io.rst b/docs/source/pygama.io.rst
index 1c3cb052d..611c5ab3d 100644
--- a/docs/source/pygama.io.rst
+++ b/docs/source/pygama.io.rst
@@ -76,6 +76,14 @@ pygama.io.orca\_digitizers module
    :undoc-members:
    :show-inheritance:
 
+pygama.io.orca\_flashcam module
+-------------------------------
+
+.. automodule:: pygama.io.orca_flashcam
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
 pygama.io.orcadaq module
 ------------------------
 
diff --git a/docs/source/pygama.rst b/docs/source/pygama.rst
index 9faf48d60..e04a814db 100644
--- a/docs/source/pygama.rst
+++ b/docs/source/pygama.rst
@@ -11,22 +11,23 @@ Subpackages
    pygama.dsp
    pygama.io
    pygama.lh5
+   pygama.sandbox
 
 Submodules
 ----------
 
-pygama.git module
------------------
+pygama.utils module
+-------------------
 
-.. automodule:: pygama.git
+.. automodule:: pygama.utils
    :members:
    :undoc-members:
    :show-inheritance:
 
-pygama.utils module
--------------------
+pygama.version module
+---------------------
 
-.. automodule:: pygama.utils
+.. automodule:: pygama.version
    :members:
    :undoc-members:
    :show-inheritance:
diff --git a/docs/source/pygama.sandbox.rst b/docs/source/pygama.sandbox.rst
new file mode 100644
index 000000000..6eaf48781
--- /dev/null
+++ b/docs/source/pygama.sandbox.rst
@@ -0,0 +1,189 @@
+pygama.sandbox package
+======================
+
+Submodules
+----------
+
+pygama.sandbox.base module
+--------------------------
+
+.. automodule:: pygama.sandbox.base
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.base\_calculators module
+---------------------------------------
+
+.. automodule:: pygama.sandbox.base_calculators
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.base\_transforms module
+--------------------------------------
+
+.. automodule:: pygama.sandbox.base_transforms
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.compass\_header module
+-------------------------------------
+
+.. automodule:: pygama.sandbox.compass_header
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.compass\_wfs module
+----------------------------------
+
+.. automodule:: pygama.sandbox.compass_wfs
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.database module
+------------------------------
+
+.. automodule:: pygama.sandbox.database
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.databases module
+-------------------------------
+
+.. automodule:: pygama.sandbox.databases
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.h5parser module
+------------------------------
+
+.. automodule:: pygama.sandbox.h5parser
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.multirun module
+------------------------------
+
+.. automodule:: pygama.sandbox.multirun
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.test module
+--------------------------
+
+.. automodule:: pygama.sandbox.test
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.test\_3302 module
+--------------------------------
+
+.. automodule:: pygama.sandbox.test_3302
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.test\_lh5\_buffered\_read module
+-----------------------------------------------
+
+.. automodule:: pygama.sandbox.test_lh5_buffered_read
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.test\_pygama module
+----------------------------------
+
+.. automodule:: pygama.sandbox.test_pygama
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.test\_tier\_0 module
+-----------------------------------
+
+.. automodule:: pygama.sandbox.test_tier_0
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.test\_tier\_1 module
+-----------------------------------
+
+.. automodule:: pygama.sandbox.test_tier_1
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.test\_wfc module
+-------------------------------
+
+.. automodule:: pygama.sandbox.test_wfc
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.tier1\_base module
+---------------------------------
+
+.. automodule:: pygama.sandbox.tier1_base
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.tier1\_vector module
+-----------------------------------
+
+.. automodule:: pygama.sandbox.tier1_vector
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.uproot\_mjd module
+---------------------------------
+
+.. automodule:: pygama.sandbox.uproot_mjd
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.uproot\_test module
+----------------------------------
+
+.. automodule:: pygama.sandbox.uproot_test
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.vec1 module
+--------------------------
+
+.. automodule:: pygama.sandbox.vec1
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+pygama.sandbox.vector module
+----------------------------
+
+.. automodule:: pygama.sandbox.vector
+   :members:
+   :undoc-members:
+   :show-inheritance:
+
+Module contents
+---------------
+
+.. automodule:: pygama.sandbox
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/tutorials.rst b/docs/source/tutorials.rst
new file mode 100644
index 000000000..609b985b3
--- /dev/null
+++ b/docs/source/tutorials.rst
@@ -0,0 +1,5 @@
+Tutorials
+=========
+
+Tutorials are currently available as `Jupyter notebooks on GitHub
+<https://github.com/legend-exp/pygama/tree/main/tutorials>`_.
diff --git a/pygama/README.md b/pygama/README.md
deleted file mode 100644
index 6039d27ea..000000000
--- a/pygama/README.md
+++ /dev/null
@@ -1,2 +0,0 @@
-## pygama
-
diff --git a/pygama/__init__.py b/pygama/__init__.py
index 448de3b2a..adae4e359 100644
--- a/pygama/__init__.py
+++ b/pygama/__init__.py
@@ -27,5 +27,6 @@
 docstring explaining what the package does. This will help users poking around
 in ipython, etc.
 """
-__version__ = "0.1.0"
+
+from pygama.version import __version__
 
diff --git a/pygama/dsp/ProcessingChain.py b/pygama/dsp/ProcessingChain.py
new file mode 100644
index 000000000..a62eed7db
--- /dev/null
+++ b/pygama/dsp/ProcessingChain.py
@@ -0,0 +1,654 @@
+import numpy as np
+import json
+import re
+import ast
+import itertools as it
+
+from pygama.dsp.units import *
+from pygama.dsp.errors import *
+
+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}
+
+
+class ProcessingChain:
+    """
+    A ProcessingChain is used to efficiently perform a sequence of digital
+    signal processing (dsp) transforms. It contains a list of dsp functions and
+    a set of constant values and named variables contained in fixed memory
+    locations. When executing the ProcessingChain, processors will act on the
+    internal memory without allocating new memory in the process. Furthermore,
+    the memory is allocated in blocks, enabling vectorized processing of many
+    entries at once. To set up a ProcessingChain, use the following methods:
+    1) add_input_buffer: bind a named variable to an external numpy array to
+         read data from
+    2) add_processor: add a dsp function and bind its inputs to a set of named
+         variables and constant values
+    3) add_output_buffer: bind a named variable to an external numpy array to
+         write data into
+    When calling these methods, the ProcessingChain class will use available
+    information to allocate buffers to the correct sizes and data types. For
+    this reason, transforms will ideally implement the numpy ufunc class,
+    enabling broadcasting of array dimensions. If not enough information is
+    available to correctly allocate memory, it can be provided through the
+    named variable strings or by calling add_vector or add_scalar.
+    """
+    def __init__(self, block_width=8, buffer_len=None, clock_unit=None, verbosity=1):
+        """Named arguments:
+        - block_width: number of entries to simultaneously process.
+        - buffer_len: length of input and output buffers. Should be a multiple
+            of block_width
+        - clock_unit: period or frequency of the clock for all waveforms.
+            constant values with time units will be converted to this unit.
+        - verbosity: integer indicating the verbosity level:
+            0: Print nothing (except errors...)
+            1: Print basic warnings (default)
+            2: Print basic debug info
+            3: Print friggin' everything!
+        """
+        # Dictionary with numpy arrays containing input and output variables
+        self.__vars_dict = {}
+        # Ordered list of processors and a tuple containing the bound parameters
+        # as either constants or variables from vars_dict
+        self.__proc_list = []
+        self.__proc_strs = []
+        # lists of tuple pairs of external buffers and internal buffers
+        self.__input_buffers = {}
+        # strings of input transforms and variable names for printings
+        self.__output_buffers = {}
+
+        self._block_width = block_width
+        self._buffer_len = buffer_len
+        self._clk = clock_unit
+        self._verbosity = verbosity
+        
+        
+    def add_waveform(self, name, dtype, length):
+        """Add named variable containing a waveform block with fixed type and length"""
+        self.__add_variable(name, dtype, (self._block_width, length))
+
+
+    def add_scalar(self, name, dtype):
+        """Add named variable containing a scalar block with fixed type"""
+        self.__add_variable(name, dtype, (self._block_width))
+
+
+    def add_input_buffer(self, varname, buff, dtype=None, buffer_len=None, unit=None):
+        """Link an input buffer to a variable. The buffer should be a numpy
+        ndarray with length that is a multiple of the buffer length, the block
+        width, and the named variable length. If any of these are unknown we
+        will try to deduce the right lengths. varname can be of the form:
+          "varname(length, type)[range]"
+        The optional (length, type) field is used to initialize a new variable.
+        The optional [range] field copies from the buffer into a subrange of the
+        array.
+
+        Optional keyword args:
+        - buffer_len: number of entries in buffer. This is needed if we cannot
+          figure it out from the shape (i.e. multi-dimensional buffer fed as
+          a one-dimensional array)
+        - dtype: data type used for the variable. Use this if the variable is
+          automatically allocated here, but with a different type from buff
+        - unit specifies a unit to convert the input from. Unit must have same type as clk, or be a ratio to multiply the input by
+        """
+        self.__add_io_buffer(buff, varname, True, dtype, buffer_len, unit)
+
+
+    def get_input_buffer(self, varname, dtype=None, buffer_len=None, unit=None):
+        """Get the input buffer associated with varname. If there is no such
+        buffer, create one with a shape compatible with the input variable and
+        return it. The input varname has the form:
+          "varname(length, type)[range]"
+        The optional (length, type) field is used to initialize a new variable.
+        The optional [range] field copies a subrange of the named array into the
+        output buffer.
+
+        Optional keyword arguments:
+        - dtype can be used to specify the numpy data type of the buffer if it
+          should differ from that held in varname
+        - buffer_len specifies the buffer length, if it has not already been set
+        - unit specifies a unit to convert the input from. Unit must have same type as clk, or be a ratio to multiply the input by
+        """
+        name = re.search('(\w+)', varname).group(0)
+        if name not in self.__input_buffers:
+            self.__add_io_buffer(None, varname, True, dtype, buffer_len, unit)
+        return self.__input_buffers[name][0]
+
+
+    def add_output_buffer(self, varname, buff, dtype=None, buffer_len=None, unit=None):
+        """Link an output buffer to a variable. The buffer should be a numpy
+        ndarray with length that is a multiple of the buffer length, the block
+        width, and the named variable length. If any of these are unknown we
+        will try to deduce the right lengths. varname can be of the form:
+          "varname(length, type)[range]"
+        The optional (length, type) field is used to initialize a new variable.
+        The optional [range] field copies a subrange of the named array into the
+        output buffer.
+
+        Optional keyword args:
+        - buffer_len: number of entries in buffer. This is needed if we cannot
+          figure it out from the shape (i.e. multi-dimensional buffer fed as
+          a one-dimensional array)
+        - dtype: data type used for the variable. Use this if the variable is
+          automatically allocated here, but with a different type from buff
+        - unit specifies a unit to convert the output into. Unit must have same type as clk, or be a ratio to divide the output by
+
+        """
+        self.__add_io_buffer(buff, varname, False, dtype, buffer_len, unit)
+
+
+    def get_output_buffer(self, varname, dtype=None, buffer_len=None, unit=None):
+        """Get the output buffer associated with varname. If there is no such
+        buffer, create one with a shape compatible with the input variable and
+        return it. The input varname has the form:
+          "varname(length, type)[range]"
+        The optional (length, type) field is used to initialize a new variable.
+        The optional [range] field copies a subrange of the named array into the
+        output buffer.
+
+        Optional keyword arguments:
+        - dtype can be used to specify the numpy data type of the buffer if it
+          should differ from that held in varname
+        - buffer_len specifies the buffer length, if it has not already been set
+        - unit specifies a unit to convert the output into. Unit must have same type as clk, or be a ratio to divide the output by
+        """
+        name = re.search('(\w+)', varname).group(0)
+        if name not in self.__output_buffers:
+            self.__add_io_buffer(None, varname, False, dtype, buffer_len, unit)
+        return self.__output_buffers[name][0]
+
+
+    def add_processor(self, func, *args, **kwargs):
+        """
+        Add a new processor and bind it to a set of parameters.
+        - func should be a function implementing the numpy ufunc class. If not,
+          then the signature and types keyword arguments will be necessary.
+        - args is a list of names and constants. The names link to internal
+          numpy array variables that will be bound to the function. Names can
+          be given in the form:
+            "varname(length, type)[range]"
+          The optional (length, type) field is used to initialize a new variable
+          if necessary. The optional [range] field copies binds only a subrange
+          of the array to the function. Non-string constant values will be
+          converted to the right type and bound to the function. Constants with
+          scimath time units will be converted to the internal clock unit.
+        - keyword arguments include:
+          - signature: broadcasting signature for a ufunc. By default, use
+            func.signature
+          - types: a list of strings defining the types of arrays needed for
+            the func. By default, use func.types
+        """
+
+        # Get the signature and list of valid types for the function
+        signature = kwargs.get("signature", None)
+        if(signature == None): signature = func.signature
+        if(signature == None):
+            signature = '->'
+            for i in range(func.nin): signature = ',()'+signature
+            for i in range(func.nout): signature = signature+'(),'
+            signature = signature.strip(',')
+
+        types = kwargs.get("types", None)
+        if(types == None): types = func.types.copy()
+        if(types == None):
+            raise ProcessingChainError("Could not find a type signature list for " + func.__name__ + ". Please supply a valid list of types.")
+        for i, typestr in enumerate(types):
+            types[i]=typestr.replace('->', '')
+
+        # make a list of parameters from *args. Replace any strings in the list
+        # with numpy objects from vars_dict, where able
+        params = []
+        for i, param in enumerate(args):
+            if(isinstance(param, str)):
+                param_val = self.get_variable(param)
+                if param_val is not None:
+                    param=param_val
+            params.append(param)
+
+        # Make sure arrays obey the broadcasting rules, and make a dictionary
+        # of the correct dimensions
+        dims_list = re.findall("\((.*?)\)", signature)
+        dims_dict = {}
+        outerdims = []
+        # print('\nsetting param:', params[-1])
+        # print('dims list is:', dims_list)
+        for ipar, dims in enumerate(dims_list):
+            # print(ipar, dims, params[ipar])
+            if not isinstance(params[ipar], np.ndarray):
+                continue
+
+            fun_dims = outerdims + [d.strip() for d in dims.split(',') if d]
+            arr_dims = list(params[ipar].shape)
+            #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
+                if(isinstance(fd, str)):
+                    # Define the dimension or make sure it is consistent
+                    if not ad or dims_dict.setdefault(fd, ad)!=ad:
+                        raise ProcessingChainError("Failed to broadcast array dimensions for "+func.__name__+". Could not find consistent value for dimension "+fd)
+                elif not fd:
+                    # if we ran out of function dimensions, add a new outer dim
+                    outerdims.insert(0, ad)
+                elif not ad:
+                    continue
+                elif fd != 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)
+                    elif len(fun_dims)<len(arr_dims):
+                        outerdims.insert(len(fun_dims)-i, ad)
+                        fun_dims.insert(len(fun_dims)-i, ad)
+                    else:
+                        raise ProcessingChainError("Failed to broadcast array dimensions for "+func.__name__+". Input arrays do not have consistent outer dimensions.")
+
+            # find type signatures that array can be cast into
+            arr_type = params[ipar].dtype.char
+            types = [type_sig for type_sig in types if np.can_cast(arr_type, type_sig[ipar])]
+
+        # Get the type signature we are using
+        if(not types):
+            error_str = "Could not find a type signature matching the types of the variables given for " + func.__name__ + str(tuple(args))
+            for param, name in zip(params, args):
+                if not isinstance(param, np.ndarray): continue
+                error_str += '\n' + name + ': ' + str(param.dtype)
+            raise ProcessingChainError(error_str)
+        # Use the first types in the list that all our types can be cast to
+        types = [np.dtype(t) for t in types[0]]
+
+        # Reshape variable arrays to add broadcast dimensions and allocate new arrays as needed
+        for i, param, dims, dtype in zip(range(len(params)), params, dims_list, types):
+            shape = outerdims + [dims_dict[d.strip()] for d in dims.split(',') if d]
+            if isinstance(param, str):
+                params[i] = self.__add_var(param, dtype, shape)
+            elif isinstance(param, np.ndarray):
+                arshape = list(param.shape)
+                for idim in range(-1, -1-len(shape), -1):
+                    if arshape[idim]!=shape[idim]:
+                        arshape.insert(len(arshape)+idim+1, 1)
+                params[i] = param.reshape(tuple(arshape))
+            else:
+                if isinstance(param, unit):
+                    param = convert(1, param, self._clk)
+                if np.issubdtype(dtype, np.integer):
+                    params[i] = dtype.type(round(param))
+                else:
+                    params[i] = dtype.type(param)
+
+        # Make strings of input variables/constants for later printing
+        proc_strs = []
+        for i, arg in enumerate(args):
+            if isinstance(arg, str):
+                proc_strs.append(arg)
+            else:
+                proc_strs.append(str(params[i]))
+        proc_strs = tuple(proc_strs)
+
+        self.__print(2, 'Added processor:', func.__name__ + str(proc_strs).replace("'", ""))
+
+        # Add the function and bound parameters to the list of processors
+        self.__proc_list.append((func, tuple(params)))
+        self.__proc_strs.append(proc_strs)
+        return None
+
+
+    def execute(self, start=0, end=None):
+        """Execute the dsp chain on the entire input/output buffers"""
+        if end is None: end = self._buffer_len
+        for begin in range(start, end, self._block_width):
+            self.execute_block(begin)
+
+
+    def execute_block(self, offset=0):
+        """Execute the dsp chain on a sub-set of the input/output buffers
+        starting at entry offset, with length equal to the internal block size.
+        """
+        end = min(offset+self._block_width, self._buffer_len)
+        self.__execute_procs(offset, end)
+
+
+    def get_variable(self, expr, get_names_only=False):
+        """Parse string expr into a numpy array or value, using the following
+        syntax:
+          - numeric values are parsed into ints or floats
+          - units found in the dsp.units module are parsed into floats
+          - other strings are parsed into variable names. If get_name_only is
+            False, fetch the internal buffer (creating it as needed). Else,
+            return a string of the name
+          - if a string is followed by (...), try parsing into one of the
+            following expressions:
+              len(expr): return the length of the array found with expr
+              round(expr): return the value found with expr to the nearest int
+              varname(shape, type): allocate a new buffer with the specified
+                shape and type, using varname. This is used if the automatic
+                type and shape deduction for allocating variables fails
+          - Unary and binary operators +, -, *, /, // are available. If
+              a variable name is included in the expression, a processor
+              will be added to the ProcChain and a new buffer allocated
+              to store the output
+          - varname[slice]: return the variable with a slice applied. Slice
+              values can be floats, and will have round applied to them
+        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
+        """
+        names = []
+        try:
+            var = self.__parse_expr(ast.parse(expr, mode='eval').body, \
+                                    not get_names_only, names)
+        except Exception as e:
+            raise ProcessingChainError("Could not parse expression:\n  " + expr) from e
+        
+        if not get_names_only:
+            return var
+        else: return names
+
+    
+    def __parse_expr(self, node, allocate_memory, var_name_list):
+        """
+        helper function for get_variable that recursively evaluates the AST tree
+        based on: https://stackoverflow.com/a/9558001. Whenever we encounter
+        a variable name, add it to var_name_list (which should begin as an
+        empty list). Only add new variables and processors to the chain if
+        allocate_memory is True
+        """
+        if node is None:
+            return None
+
+        elif isinstance(node, ast.Num):
+            return node.n
+
+        elif isinstance(node, ast.Str):
+            return node.s
+
+        elif isinstance(node, ast.Constant):
+            return node.val
+
+        # look for name in variable dictionary
+        elif isinstance(node, ast.Name):
+            # check if it is a unit
+            val = unit_parser.parse_unit(node.id)
+            if val.is_valid():
+                try:
+                    return convert(1, val, self._clk)
+                except:
+                    return None
+
+            #check if it is a variable
+            var_name_list.append(node.id)
+            val = self.__vars_dict.get(node.id, None)
+            return val
+
+        # define binary operators (+,-,*,/)
+        elif isinstance(node, ast.BinOp):
+            lhs = self.__parse_expr(node.left, allocate_memory, var_name_list)
+            if lhs is None: return None
+            rhs = self.__parse_expr(node.right, allocate_memory, var_name_list)
+            if rhs is None: return None
+            op = ast_ops_dict[type(node.op)]
+            if isinstance(lhs, np.ndarray) or isinstance(rhs, np.ndarray):
+                if not isinstance(lhs, np.ndarray):
+                    if np.issubdtype(rhs.dtype, np.integer):
+                        lhs = rhs.dtype.type(round(lhs))
+                    else:
+                        lhs = rhs.dtype.type(lhs)
+                if not isinstance(rhs, np.ndarray):
+                    if np.issubdtype(lhs.dtype, np.integer):
+                        rhs = lhs.dtype.type(round(rhs))
+                    else:
+                        rhs = lhs.dtype.type(rhs)
+                out = op(lhs, rhs)
+                if allocate_memory:
+                    self.__proc_list.append((op, (lhs, rhs, out)))
+                    self.__proc_strs.append("Binary operator: " + op.__name__)
+                return out
+            return op(lhs, rhs)
+
+        # define unary operators (-)
+        elif isinstance(node, ast.UnaryOp):
+            operand = self.__parse_expr(node.operand, allocate_memory, var_name_list)
+            if operand is None: return None
+            op = ast_ops_dict[type(node.op)]
+            out = op(operand)
+            # if we have a np array, add to processor list
+            if isinstance(out, np.ndarray) and allocate_memory:
+                self.__proc_list.append((op, (operand, out)))
+                self.__proc_strs.append("Unary operator: " + op.__name__)
+            return out
+
+        elif isinstance(node, ast.Subscript):
+            # print(ast.dump(node))
+            val = self.__parse_expr(node.value, allocate_memory, var_name_list)
+            if val is None: return None
+            if isinstance(node.slice, ast.Index):
+                if isinstance(val, np.ndarray):
+                    return val[..., self.__parse_expr(node.slice.value, allocate_memory, var_name_list)]
+                else:
+                    return val[self.__parse_expr(node.slice.value, allocate_memory, var_name_list)]
+            elif isinstance(node.slice, ast.Slice):
+                if isinstance(val, np.ndarray):
+                    return val[..., slice(self.__parse_expr(node.slice.lower, allocate_memory, var_name_list),
+                                          self.__parse_expr(node.slice.upper, allocate_memory, var_name_list),
+                                          self.__parse_expr(node.slice.step, allocate_memory, var_name_list) )]
+                else:
+                    return val[slice(self.__parse_expr(node.slice.upper, allocate_memory, var_name_list),self.__parse_expr(node.slice.lower, allocate_memory, var_name_list),self.__parse_expr(node.slice.step, allocate_memory, var_name_list))]
+            elif isinstance(node.slice, ast.ExtSlice):
+                slices = tuple(node.slice.dims)
+                for i, sl in enumerate(slices):
+                    if isinstance(sl, ast.index):
+                        slices[i] = self.__parse_expr(sl.value, allocate_memory, var_name_list)
+                    else:
+                        slices[i] = slice(self.__parse_expr(sl.upper, allocate_memory, var_name_list),
+                                          self.__parse_expr(sl.lower, allocate_memory, var_name_list),
+                                          self.__parse_expr(sl.step, allocate_memory, var_name_list) )
+                return val[..., slices]
+
+        # for name.attribute
+        elif isinstance(node, ast.Attribute):
+            val = self.__parse_expr(node.value, allocate_memory, var_name_list)
+            if val is None: return None
+            # get shape with buffer_len dimension removed
+            if node.attr=='shape' and isinstance(val, np.ndarray):
+                return val.shape[1:]
+
+        # for func([args])
+        elif isinstance(node, ast.Call):
+            func = node.func.id
+            # get length of 1D array variable
+            if func=="len" and len(node.args)==1 and isinstance(node.args[0], ast.Name):
+                var = self.__parse_expr(node.args[0], allocate_memory, var_name_list)
+                if var is None: return None
+                if isinstance(var, np.ndarray) and len(var.shape)==2:
+                    return var.shape[1]
+                else:
+                    raise ProcessingChainError("len(): " + node.args[0].id + " has wrong number of dims")
+            elif func=="round" and len(node.args)==1:
+                var = self.__parse_expr(node.args[0], allocate_memory, var_name_list)
+                if var is None: return None
+                return int(round(var))
+            # if this is a valid call to construct a new array, do so; otherwise raise an exception
+            else:
+                if len(node.args)==2:
+                    shape = self.__parse_expr(node.args[0], allocate_memory, var_name_list)
+                    if shape is None:
+                        shape = (self._block_width,)
+                    if isinstance(shape, (int, np.int32, np.int64)):
+                        shape = (self._block_width, shape)
+                    elif isinstance(shape, tuple):
+                        shape = (self._block_width, ) + shape
+                    else:
+                        raise ProcessingChainError("Do not recognize call to "+func+" with arguments of types " + str([arg.__dict__ for arg in node.args]))
+                    try: dtype = np.dtype(node.args[1].id)
+                    except: raise ProcessingChainError("Do not recognize call to "+func+" with arguments of types " + str([arg.__dict__ for arg in node.args]))
+                    
+                    var_name_list.append(func)
+                    
+                    if func in self.__vars_dict:
+                        var = self.__vars_dict[func]
+                        if not var.shape==shape and var.dtype==dtype:
+                            raise ProcessingChainError("Requested shape and type for " + func + " do not match existing values")
+                        return var
+                    else:
+                        var = np.zeros(shape, dtype, 'F')
+                        if allocate_memory:
+                            self.__vars_dict[func] = var
+                            self.__print(2, 'Added variable', func, 'with shape', tuple(shape), 'and type', dtype)
+
+                        return var
+                else:
+                    raise ProcessingChainError("Do not recognize call to "+func+" with arguments " + str([str(arg.__dict__) for arg in node.args]))
+
+        raise ProcessingChainError("Cannot parse AST nodes of type " + str(node.__dict__))
+
+
+    def __add_var(self, name, dtype, shape):
+        """
+        Add an array of zeros to the vars dict called name and return it
+        """
+        if not re.match("\A\w+$", name):
+            raise ProcessingChainError(name+' is not a valid alphanumeric name')
+        if name in self.__vars_dict:
+            raise ProcessingChainError(name+' is already in variable list')
+        arr = np.zeros(shape, dtype)
+        self.__vars_dict[name] = arr
+        self.__print(2, 'Added variable', re.search('(\w+)', name).group(0), 'with shape', tuple(shape), 'and type', dtype)
+        return arr
+
+    
+    def __execute_procs(self, start, end):
+        """
+        copy from input buffers to variables
+        call all the processors on their paired arg tuples
+        copy from variables to list of output buffers
+        """
+        # Track names that have been printed so we only print each variable once
+        if self._verbosity >= 3:
+            names = set(self.__vars_dict.keys())
+            self.__print(3, 'Input:')
+
+        # Copy input buffers into proc chain buffers
+        for name, (buf, var, scale) in self.__input_buffers.items():
+            if scale:
+                np.multiply(buf[start:end, ...], scale, var[0:end-start, ...])
+            else:
+                np.copyto(var[0:end-start, ...], buf[start:end, ...], 'unsafe')
+
+            if self._verbosity >= 3:
+                self.__print(3, name, '=', var)
+                names.discard(name)
+
+        # Loop through processors and run each one
+        self.__print(3, 'Processing:')
+        for (func, args), strs in zip(self.__proc_list, self.__proc_strs):
+            try:
+                func(*args)
+            except DSPFatal as e:
+                e.processor = func.__name__ + str(strs).replace("'", "")
+                e.wf_range = (start, end)
+                raise e
+                
+            if self._verbosity >= 3:
+                self.__print(3, func.__name__ + str(strs).replace("'", ""))
+                for name, arg in zip(strs, args):
+                    try:
+                        names.remove(name)
+                        self.__print(3, name, '=', arg)
+                    except: pass
+
+        # copy from processing chain buffers into output buffers
+        self.__print(3, 'Output:')
+        for name, (buf, var, scale) in self.__output_buffers.items():
+            if scale:
+                np.divide(var[0:end-start, ...], scale, buf[start:end, ...])
+            else:
+                np.copyto(buf[start:end, ...], var[0:end-start, ...], 'unsafe')
+
+            self.__print(3, name, '=', var)
+
+    
+    def __add_io_buffer(self, buff, varname, input, dtype, buffer_len, scale):
+        """
+        append a tuple with the buffer and variable to either the input buffer
+        list (if input=true) or output buffer list (if input=false), making sure
+        that buffer shapes are compatible
+        """
+        var = self.get_variable(varname)
+        if buff is not None and not isinstance(buff, np.ndarray):
+            raise ProcessingChainError("Buffers must be ndarrays.")
+
+        # if buffer length is not defined, figure out what it should be
+        if buffer_len is not None:
+            if self._buffer_len is None:
+                self._buffer_len = buffer_len
+            elif self._buffer_len != buffer_len:
+                raise ProcessingChainError("Buffer length was already set to a number different than the one provided. To change the buffer length, you must reset the buffers.")
+        if not self._buffer_len:
+            if buff is not None: self._buffer_len = buff.shape[0]
+            else: self._buffer_len = self._block_width
+            self.__print(1, "Setting i/o buffer length to", self._buffer_len)
+
+        # if a unit is given, convert it to a scaling factor
+        if isinstance(scale, unit):
+            scale = convert(1, scale, self._clk)
+
+        # if no buffer was provided, make one
+        returnbuffer=False
+        if buff is None:
+            if var is None:
+                raise ProcessingChainError("Cannot make buffer for non-existent variable " + varname)
+            # deduce dtype. If scale is used, force float type
+            if not dtype:
+                if not scale:
+                    dtype=var.dtype
+                else:
+                    dtype=np.dtype('float'+str(var.dtype.itemsize*8))
+            buff = np.zeros((self._buffer_len,)+var.shape[1:], dtype)
+            returnbuffer=True
+
+        # Check that the buffer length is correct. For 1D buffer, reshape
+        # it if possible, assuming array of structure ordering
+        if not buff.shape[0] == self._buffer_len:
+            if buff.ndim==1 and len(buff)%self._buffer_len==0:
+                buff = buff.reshape(self._buffer_len, len(buff)//self._buffer_len)
+            else:
+                raise ProcessingChainError("Buffer provided for " + varname + " is the wrong length.")
+
+        # Check that shape of buffer is compatible with shape of variable.
+        # If variable does not yet exist, add it here
+        if var is None:
+            if dtype is None:
+                if not scale:
+                    dtype=buff.dtype
+                else:
+                    dtype=np.dtype('float'+str(buff.dtype.itemsize*8))
+            var = self.__add_var(varname, dtype, (self._block_width,)+buff.shape[1:])
+        elif var.shape[1:] != buff.shape[1:]:
+            raise ProcessingChainError("Provided buffer has shape " + str(buff.shape) + " which is not compatible with " + str(varname) + " shape " + str(var.shape))
+
+        varname = re.search('(\w+)', varname).group(0)
+        if input:
+            self.__input_buffers[varname]=(buff, var, scale)
+            self.__print(2, 'Binding input buffer of shape', buff.shape, 'and type', buff.dtype, 'to variable', varname, 'with shape', var.shape, 'and type', var.dtype)
+        else:
+            self.__output_buffers[varname]=(buff, var, scale)
+            self.__print(2, 'Binding output buffer of shape', buff.shape, 'and type', buff.dtype, 'to variable', varname, 'with shape', var.shape, 'and type', var.dtype)
+
+        if returnbuffer: return buff
+
+
+    def __print(self, verbosity, *args, **kwargs):
+        """Helper for output that checks verbosity before printing and
+        converts things into strings. At verbosity 0, print to stderr"""
+        if verbosity==0 and not 'file' in kwargs:
+            kwargs['file']=sys.stderr
+        if self._verbosity >= verbosity:
+            print(*[str(arg) for arg in args], **kwargs)
+
+
+    def __str__(self):
+        ret = 'Input variables: ' + str([name for name in self.__input_buffers.keys()])
+        for proc, strs in zip(self.__proc_list, self.__proc_strs):
+            ret += '\n' + proc[0].__name__ + str(strs)
+        ret += '\nOutput variables: ' + str([name for name in self.__output_buffers.keys()])
+        return ret.replace("'", "")
+
diff --git a/pygama/dsp/WaveformBrowser.py b/pygama/dsp/WaveformBrowser.py
new file mode 100644
index 000000000..9917fe615
--- /dev/null
+++ b/pygama/dsp/WaveformBrowser.py
@@ -0,0 +1,401 @@
+from pygama.io.raw_to_dsp import build_processing_chain
+from pygama import lh5
+import pygama.dsp.units as units
+
+import glob, os, itertools, contextlib, string
+import numpy as np
+import pandas as pd
+import matplotlib.pyplot as plt
+from cycler import cycler
+
+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 raw_to_dsp style json files,
+    drawing horizontal and verticle lines at the values of calculated
+    parameters, and filling a legend with calculated parameters.
+    """
+
+    def __init__(self, files_in, lh5_group, dsp_config = None, database = None,
+                 n_drawn = 1, x_unit = 'ns', x_lim=None, y_lim=None,
+                 waveforms = 'waveform', wf_styles = None, lines = None,
+                 legend = None, legend_opts = None, norm = None, align=None,
+                 selection = None,
+                 buffer_len = 128, block_width = 8, verbosity=1):
+        """Constructor for WaveformBrowser:
+        - file_in: name of file or list of names to browse. Can use wildcards
+        - lh5_group: name of LH5 group in file to browse
+        - dsp_config (optional): name of DSP config json file or dict containing transforms available to draw
+        - database (optional): dict with database of processing parameters
+        - n_drawn (default 1): number of events to draw simultaneously when calling DrawNext
+        - x_unit (default ns): unit for x-axis
+        - x_lim (default auto): range of x-values passes as tuple
+        - waveforms (default 'waveform'): name of wf or list of wf names to draw
+        - wf_styles (default None): waveform colors and other style parameters to cycle through when 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 style
+          If a single style cycle is given, use for all lines; if a list is given, match to waveforms list.
+        - lines (default None): name of parameter or list of parameters to draw hlines and vlines for
+        - legend (default None): formatting string and values to include in the legend. This can be a list of values (one for each waveform in waveforms). The values can be given as a tuple whose first entry is a formatting string and subsequent entries are the values to place in the formatting string. When building a formatting string, if a name is given in the {}s, it is assumed to be a parameter from the DSP config file. An example is:
+          ("{:0.1f} keV", energy)
+        - legend_opts (default None): dict containing kwargs for formatting the legend
+        - norm (default None): name of parameter (probably energy) to use to normalize WFs; useful when drawing multiple
+        - align (default None): name of time parameter to set as 0 time; useful for aligning multiple waveforms
+        - selection (optional): selection of events to draw. Can be either a list of event indices or a numpy array mask (ala pandas).
+        - buffer_len (default 128): number of waveforms to keep in memory at a time
+        - block_width (default 8): block width for processing chain
+        """
+        self.verbosity = verbosity
+
+        # data i/o initialization
+        self.lh5_st = lh5.Store(keep_open=True)
+        if isinstance(files_in, str): files_in = [files_in]
+
+        # Expand wildcards and map out the files
+        self.lh5_files = [f for f_wc in files_in for f in sorted(glob.glob(os.path.expandvars(f_wc)))]
+        self.lh5_group = lh5_group
+        # file map is cumulative lenght of files up to file n. By doing searchsorted left, we can get the file for a given wf index
+        self.file_map = np.array([self.lh5_st.read_n_rows(lh5_group, f) for f in self.lh5_files], 'int64')
+        np.cumsum(self.file_map, out=self.file_map)
+
+        # Get the input buffer and read the first chunk
+        self.lh5_in = self.lh5_st.get_buffer(self.lh5_group, self.lh5_files[0], buffer_len)
+        self.lh5_st.read_object(self.lh5_group, self.lh5_files[0], start_row=0, n_rows=buffer_len, obj_buf=self.lh5_in)
+        self.buffer_len = buffer_len
+        self.current_file = None
+        self.current_chunk = None
+
+        # initialize stuff for iteration
+        self.selection = selection
+        self.index_it = None
+        self.reset()
+        self.n_drawn = n_drawn
+
+        # initialize list of objects to draw
+        if isinstance(waveforms, str): self.wf_names = [waveforms]
+        elif waveforms is None: self.wf_names = []
+        else: self.wf_names = list(waveforms)
+        self.wf_data = [ [] for _ in self.wf_names ]
+
+        # wf_styles
+        if isinstance(wf_styles, list) or isinstance(wf_styles, tuple):
+            self.wf_styles = [ None for _ in self.wf_data ]
+            for i, sty in enumerate(wf_styles):
+                if isinstance(sty, str):
+                    try:
+                        self.wf_styles[i] = plt.style.library[sty]['axes.prop_cycle']
+                    except:
+                        self.wf_styles[i] = itertools.repeat(None)
+                elif sty is None:
+                    self.wf_styles[i] = itertools.repeat(None)
+                else:
+                    self.wf_styles[i] = cycler(**sty)
+        else:
+            if isinstance(wf_styles, str):
+                try:
+                    self.wf_styles = plt.style.library[wf_styles]['axes.prop_cycle']
+                except:
+                    self.wf_styles = itertools.repeat(None)
+            elif wf_styles is None:
+                self.wf_styles = itertools.repeat(None)
+            else:
+                self.wf_styles = cycler(**wf_styles)
+
+        if lines is None: self.line_names = []
+        elif isinstance(lines, list): self.line_names = lines
+        elif isinstance(lines, tuple):  self.line_names = list(lines)
+        else: self.line_names = [lines]
+        self.line_data = [ [] for _ in self.line_names ]
+
+        if legend is None: legend = []
+        elif not isinstance(legend, list): legend = [legend]
+
+        # Set up the legend format strings and collect input values
+        self.legend_input = []
+        self.legend_format = []
+        for entry in legend:
+            legend_input = []
+            legend_format = ''
+            if not isinstance(entry, tuple):
+                entry = (entry,)
+
+            for val in entry:
+                if isinstance(val, str):
+                    for st, name, form, cv in string.Formatter().parse(val):
+                        legend_format += st
+                        if name is not None:
+                            legend_format += '{'
+                            legend_input.append(name)
+                            if form is not None and form != '':
+                                legend_format += ':' + form
+                            if cv is not None and cv != '':
+                                legend_format += '!' + cv
+                            legend_format += '}'
+                else:
+                    # find any {}s to fill from the formatter
+                    idxs = [i for i, inp in enumerate(legend_input) if isinstance(inp,str) and inp=='']
+                    if idxs: # if we found a {}. it's already in the formatter
+                        legend_input[idxs[0]] = val
+                    else: # otherwise add to formatter
+                        legend_input.append(val)
+                        if legend_format!='': legend_format += ', '
+                        if isinstance(val, pd.Series):
+                            legend_format += val.name + ' = {:.3g}'
+                        elif isinstance(val, np.ndarray):
+                            legend_format += '{:.3g}'
+            self.legend_input.append(legend_input)
+            self.legend_format.append(legend_format)
+
+        self.legend_data = [ [] for _ in self.legend_input ]
+        self.legend_kwargs = legend_opts if legend_opts else {}
+
+        self.norm_par = norm
+        self.align_par = align
+
+        self.x_unit = units.unit_parser.parse_unit(x_unit)
+        self.x_lim = x_lim
+        self.y_lim = y_lim
+
+        # make processing chain and output buffer
+        outputs = self.wf_names + \
+                  [name for name in self.line_names if isinstance(name, str)] + \
+                  [name for name in self.legend_input  if isinstance(name, str)]
+        if isinstance(self.norm_par, str): outputs += [self.norm_par]
+        if isinstance(self.align_par, str): outputs += [self.align_par]
+
+        self.proc_chain, self.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)
+
+        self.fig = None
+        self.ax = None
+
+    def new_figure(self):
+        """Create a new figure and draw in it"""
+        self.fig, self.ax = plt.subplots(1)
+
+    def save_figure(self, f_out=None):
+        if f_out is not None:
+            self.fig.savefig(f_out)
+        else:
+            print("You need to specify an output filename!")
+
+    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"""
+        if isinstance(fig, WaveformBrowser):
+            self.fig = fig.fig
+            self.ax = fig.ax
+        elif isinstance(fig, plt.Figure):
+            self.fig = fig
+            if ax is None:
+                self.ax = fig.axes[0]
+            elif isinstance(ax, plt.Axes):
+                self.ax = ax
+            else:
+                raise TypeError("ax must be matplotlib.Axis")
+        else:
+            raise TypeError("fig must be matplotlib.Figure or WaveformBrowser")
+
+    def clear_data(self):
+        for wf_set in self.wf_data: wf_set.clear()
+        for line_set in self.line_data: line_set.clear()
+        for leg_data in self.legend_data: leg_data.clear()
+
+    def find_entry(self, entry, append=True):
+        """
+        Find the requested data associated with entry in input files and
+        place it in self.wf_data, self.line_data and self.legend_data. Set
+        append to False to clear these buffers before fetching the entry/ies.
+        Can give a list/tuple to find multiple entries.
+        """
+        if not append: self.clear_data()
+        if isinstance(entry, list) or isinstance(entry, tuple):
+            for idx in entry: self.find_entry(idx)
+            return
+
+        # figure out which file we are reading from and the chunk/index within the file, using the file map
+        file_no = np.searchsorted(self.file_map, entry, 'left')
+        if file_no>len(self.lh5_files):
+            raise IndexError
+        # get chunk and index within this chunk
+        file_beg = self.file_map[file_no-1] if file_no>0 else 0
+        chunk, index = divmod(entry - file_beg, self.buffer_len)
+
+        # Update the chunk as needed
+        if file_no != self.current_file or chunk != self.current_chunk:
+            self.current_chunk = chunk
+            self.current_file = file_no
+            self.lh5_in, n_read = self.lh5_st.read_object(self.lh5_group,
+                                                          self.lh5_files[file_no],
+                                                          start_row=chunk*self.buffer_len,
+                                                          n_rows=len(self.lh5_in),
+                                                          field_mask = self.field_mask,
+                                                          obj_buf=self.lh5_in)
+            self.proc_chain.execute(0, n_read)
+
+
+        # get scaling factor/time shift if used
+        if self.norm_par is None:
+            norm = 1.
+        elif isinstance(self.norm_par, str):
+            norm = self.lh5_out[self.norm_par].nda[index]
+        else:
+            norm = self.norm_par[entry]
+
+        if self.align_par is None:
+            ref_time = 0
+        elif isinstance(self.align_par, str):
+            unit = self.lh5_out[self.align_par].attrs['units']
+            dt = units.convert(1, units.unit_parser.parse_unit(unit), self.x_unit)
+            ref_time = self.lh5_out[self.align_par].nda[index]*dt
+        else:
+            ref_time = self.align_par[entry]
+
+        leg_handle = None
+
+        #waveforms
+        for wf_name, wf_data in zip(self.wf_names, self.wf_data):
+            # Get the data; note this is implicitly copying it!
+            y = self.lh5_out[wf_name].nda[index]/norm
+            if self.x_unit.derivation == self.proc_chain._clk.derivation:
+                # this is a WF
+                dt = units.convert(1, self.proc_chain._clk, self.x_unit)
+                x = np.linspace(-ref_time, len(y)*dt-ref_time, len(y), 'f')
+            elif self.x_unit.derivation == (1/self.proc_chain._clk).derivation:
+                # this is a FT
+                f_nyq = units.convert(1, 0.5/self.proc_chain._clk, self.x_unit)
+                x = np.linspace(0, f_nyq, len(y), 'f')
+            wf_data.append((x, y))
+
+        # lines
+        for line_name, line_data in zip(self.line_names, self.line_data):
+            try: # if unit is time, do vline
+                unit = units.unit_parser.parse_unit(self.lh5_out[line_name].attrs['units'])
+                val = self.lh5_out[line_name].nda[index]*unit
+                val -= ref_time*self.x_unit
+
+            except: # else do hline
+                val = self.lh5_out[line_name].nda[index]/norm
+
+            line_data.append(val)
+
+        # legend data
+        for legend_input, legend_data in zip(self.legend_input, self.legend_data):
+            leg_vals = []
+            for val in legend_input:
+                if isinstance(val, str):
+                    leg_vals.append(self.lh5_out[val].nda[index])
+                else:
+                    leg_vals.append(val[entry])
+            legend_data.append(leg_vals)
+
+    def draw_current(self, clear=True):
+        """
+        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()
+
+        if clear:
+            self.ax.clear()
+
+        leg_handles = []
+        leg_labels = []
+        if not isinstance(self.wf_styles, list):
+            wf_styles = self.wf_styles
+
+        # draw waveforms
+        for i, wf_set in enumerate(self.wf_data):
+            if isinstance(self.wf_styles, list):
+                wf_styles = self.wf_styles[i]
+            if wf_styles is None:
+                wf_styles = itertools.repeat(None)
+
+            for wf, sty in zip(wf_set, wf_styles):
+                if sty is None:
+                    wf_line, = self.ax.plot(*wf, '-')
+                else:
+                    wf_line, = self.ax.plot(*wf, **sty)
+                leg_handles.append(wf_line)
+
+        # draw legend
+        for legend_data, legend_format in zip(self.legend_data, self.legend_format):
+            for legend_vals in legend_data:
+                leg_labels.append(legend_format.format(*legend_vals))
+
+        # draw hlines and vlines
+        for lines in self.line_data:
+            for val in lines:
+                if isinstance(val, units.unit):
+                    self.ax.axvline(units.convert(1, val, self.x_unit))
+                else:
+                    self.ax.axhline(val)
+
+        self.ax.set_xlabel(self.x_unit.label)
+        self.ax.xaxis.set_label_coords(0.98, -0.05)
+        if self.x_lim:
+            self.ax.set_xlim(*self.x_lim)
+        if self.y_lim:
+            self.ax.set_ylim(*self.y_lim)
+        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
+            self.ax.legend(leg_handles, leg_labels, **self.legend_kwargs)
+
+
+    def draw_entry(self, entry, append=False, clear=True):
+        """Draw specified entries from file. Entry_list can be either a single value or list of values representing the index of an event within all files. If append is True, previously drawn entries will be drawn along with this one. If clear is False, the axis will not be cleared before drawing. Return the axis object"""
+        self.find_entry(entry, append)
+        self.draw_current(clear)
+
+    def find_next(self, n_wfs = None, append = False):
+        """Find the next n_wfs (default to self.n_drawn) waveforms indicated by self.selection and place them in self.wf_data, self.line_data and self.legend_data. If append is True, do not clear these buffers first."""
+        if not n_wfs: n_wfs = self.n_drawn
+
+        wf_indices = [ i_wf for _, i_wf in zip(range(n_wfs), self.index_it) ]
+        if len(wf_indices) < n_wfs: self.eof=True
+        self.find_entry(wf_indices, append)
+
+        return wf_indices
+
+    def draw_next(self, n_wfs = None, append = False, clear = True):
+        """Draw the next n_wfs waveforms on the same axis. If a selection was set, only draw waveforms from that selection. Return a list of waveform indices drawn and the axis object:
+           n_wfs: number of waveforms to draw (default is self.n_wfs)
+           append: set to True to prevent clearing of axis before drawing
+           clear: set False to draw on an already defined axis
+        """
+        wf_indices = self.find_next(n_wfs, append)
+        self.draw_current(clear)
+
+        return wf_indices
+
+    def reset(self):
+        """ Reset to the start of the file for draw_next """
+        self.eof = False
+        try:
+            if self.selection is None:
+                self.index_it = iter(range(0, self.file_map[-1]))
+            elif isinstance(self.selection, list) or isinstance(self.selection, tuple): # index list
+                self.index_it = iter(self.selection)
+            elif isinstance(self.selection, np.ndarray): # numpy boolean mask
+                self.index_it = iter(np.nonzero(self.selection)[0] )
+            elif isinstance(self.selection, pd.Series): # pandas series mask
+                self.index_it = iter(np.nonzero(self.selection.values)[0] )
+            else:
+                raise Exception
+        except:
+            print("Not sure what to do with selection", self.selection, "("+str(self.selection.__class__)+")")
+
+    def __iter__(self):
+        self.reset()
+        return self
+
+    def __next__(self):
+        """ Call draw_next... """
+        if self.eof:
+            raise StopIteration
+        return self.draw_next()
diff --git a/pygama/dsp/__init__.py b/pygama/dsp/__init__.py
index e69de29bb..80472d15b 100644
--- a/pygama/dsp/__init__.py
+++ b/pygama/dsp/__init__.py
@@ -0,0 +1,13 @@
+"""
+The pygama signal processing framework is contained in the :mod:`pygama.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:
+
+* 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.
+* :class:`ProcessingChain`: A class that manages and efficiently
+  runs a list of DSP processors
+"""
diff --git a/pygama/dsp/_processors/bl_subtract.py b/pygama/dsp/_processors/bl_subtract.py
index 5dd06d0e2..7c56ba79b 100644
--- a/pygama/dsp/_processors/bl_subtract.py
+++ b/pygama/dsp/_processors/bl_subtract.py
@@ -2,39 +2,35 @@
 from numba import guvectorize
 from pygama.dsp.errors import DSPFatal
 
-@guvectorize(["void(float32[:], uint16, float32[:])",
-              "void(float64[:], uint16, float64[:])"],
+@guvectorize(["void(float32[:], float32, float32[:])",
+              "void(float64[:], float64, float64[:])"],
              "(n),()->(n)", nopython=True, cache=True)
-
 def bl_subtract(w_in, a_baseline, w_out):
     """
-    Processor to subtract the fpga baseline from all waveform values. If any
-    input values are nan will return array of nan of w_out.
+    Subtract the constant baseline from the entire waveform.
+
     Parameters
     ----------
     w_in : array-like
-           wf to baseline subtract
-    a_in : uint
-           The baseline values to subtract, these are stored as uints in the raw files
-    w_out : array-like
-            The output waveform from the processor
+           The input waveform
+    a_in : float
+           The baseline value to subtract
+    w_out: array-like
+           The output waveform with the baseline subtracted
+
     Processing Chain Example
     ------------------------
-    "wf_blsub":{
+    "wf_bl": {
         "function": "bl_subtract",
         "module": "pygama.dsp.processors",
-        "args": ["waveform", "baseline", "wf_blsub"],
-        "prereqs": ["waveform", "baseline"],
-        "unit": "ADC"
-        },
+        "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)):
+    if np.isnan(w_in).any() or np.isnan(a_baseline):
         return
 
-    if (not a_baseline >= 0):
-        raise DSPFatal('a_baseline is out of range')
-
     w_out[:] = w_in[:] - a_baseline
diff --git a/pygama/dsp/_processors/convolutions.py b/pygama/dsp/_processors/convolutions.py
index 7a0aa037e..4d6f96823 100644
--- a/pygama/dsp/_processors/convolutions.py
+++ b/pygama/dsp/_processors/convolutions.py
@@ -1,217 +1,232 @@
 import numpy as np
 from numba import guvectorize
-import math
-from math import pow
 from pygama.dsp.errors import DSPFatal
 
 def cusp_filter(length, sigma, flat, decay):
-    
     """
-    This processor applies a CUSP filter to the waveform for use in energy estimation. 
-    It is composed of a factory function that is called using the init_args argument and the function the waveforms are passed to using args
-    Initialisation Parameters
-    ------------------------
-    length : int 
-             The length of the filter to be convolved with the waveforms generally specified as len(wf)-some number
-    sigma : int, float
-             Parameter determining the curvature of the rising and faling part of the kernel
-    flat : int
-            Size of the flat section of the CUSP filter 
+    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.
+
+    Initialization 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
-            Decay constant of the exponential to be convolved with the CUSP kernel
+            The decay constant of the exponential to be convolved
+
     Parameters
     ----------
     w_in : array-like
-            waveform to be convolved with CUSP filter
-    w_out : array-like
-        waveform convolved with CUSP filter
+           The input waveform
+    w_out: array-like
+           The filtered waveform
+
     Processing Chain Example
     ------------------------
     "wf_cusp": {
         "function": "cusp_filter",
         "module": "pygama.dsp.processors",
-        "args": [ "wf_blsub", "wf_cusp(101,f)" ],
-        "init_args" : ["len(wf_blsub)-100", "40*us", "3*us", "45*us"],
-        "prereqs": [ "wf_blsub" ],
-        "unit": "ADC"
-        },
+        "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 (not length > 0):
-        raise DSPFatal('length out of range, must be greater than 0')
-    if (not sigma >= 0):
-        raise DSPFatal('sigma out of range, must be >= 0')
+    if sigma < 0:
+        raise DSPFatal('The curvature parameter must be positive')
 
-    if (not flat >= 0):
-        raise DSPFatal('flat out of range, must be >= 0')
-    if (not decay >= 0):
-        raise DSPFatal('decay out of range, must be >= 0')
+    if flat < 0:
+        raise DSPFatal('The length of the flat section must be positive')
 
-    lt = int((length-flat)/2)
+    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(lt):
-        cusp[ind] = float(math.sinh(ind/sigma)/math.sinh(lt/sigma))
-    for ind in range(lt, lt+flat_int+1, 1):
+    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(math.sinh((length-ind)/sigma)/math.sinh(lt/sigma))
-
+    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)]
+    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):
-
-        w_out[:]= np.nan
+    def cusp_out(w_in, w_out):
+        w_out[:] = np.nan
 
-        if (np.isnan(w_in).any()):
+        if np.isnan(w_in).any():
             return
 
-        if (len(cuspd)> len(w_in)):
-            raise DSPFatal('Filter longer than input waveform')
+        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):
-    
     """
-    This processor applies a ZAC (Zero Area Cusp) filter to the waveform for use in energy estimation. 
-    It is composed of a factory function that is called using the init_args argument and the function the waveforms are passed to using args
-    Initialisation Parameters
-    ------------------------
-    length : int 
-             The length of the filter to be convolved with the waveforms generally specified as len(wf)-some number
-    sigma : int, float
-             Parameter determining the curvature of the rising and faling part of the kernel
-    flat : int
-            Size of the flat section of the ZAC filter 
+    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.
+
+    Initialization 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
-            Decay constant of the exponential to be convolved with the ZAC kernel
+            The decay constant of the exponential to be convolved
+
     Parameters
     ----------
     w_in : array-like
-            waveform to be convolved with ZAC filter
-    w_out : array-like
-        waveform convolved with ZAC filter
+           The input waveform
+    w_out: array-like
+           The filtered waveform
+
     Processing Chain Example
     ------------------------
     "wf_zac": {
         "function": "zac_filter",
         "module": "pygama.dsp.processors",
-        "args": [ "wf_blsub", "wf_zac(101,f)" ],
-        "init_args" : ["len(wf_blsub)-100", "40*us", "3*us", "45*us"],
-        "prereqs": [ "wf_blsub" ],
-        "unit": "ADC"
-        },
+        "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 (not length > 0):
-        raise DSPFatal('length out of range, must be greater than 0')
-    if (not sigma >= 0):
-        raise DSPFatal('sigma out of range, must be >= 0')
+    if flat < 0:
+        raise DSPFatal('The length of the flat section must be positive')
 
-    if (not flat >= 0):
-        raise DSPFatal('flat out of range, must be >= 0')
-    if (not decay >= 0):
-        raise DSPFatal('decay out of range, must be >= 0')
+    if decay < 0:
+        raise DSPFatal('The decay constant must be positive')
 
-    lt = int((length-flat)/2)
+    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(lt):
-        cusp[ind] = float(math.sinh(ind/sigma)/math.sinh(lt/sigma))
-        par[ind] = pow(ind-lt/2,2)-pow(lt/2,2)
-    for ind in range(lt, lt+flat_int+1, 1):
+    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(math.sinh((length-ind)/sigma)/math.sinh(lt/sigma))
-        par[ind] = pow(length-ind-lt/2,2)-pow(lt/2,2)
+    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(length):
-        areapar += par[i]
+    for i in range(0, length, 1):
+        areapar  += par [i]
         areacusp += cusp[i]
-    #normalize parables area
-    par = -par/areapar*areacusp
-    #create zac filter
+
+    # normalize parables area
+    par = -par / areapar * areacusp
+
+    # create zac filter
     zac = cusp + par
-    #deconvolve zac filter
-    den = [1, -np.exp(-1/decay)]
+
+    # 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):
-
+    def zac_out(w_in, w_out):
         w_out[:] = np.nan
 
-        if (np.isnan(w_in).any()):
+        if np.isnan(w_in).any():
             return
 
-        if (len(zacd) > len(w_in)):
-            raise DSPFatal('Filter longer than input waveform')
+        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):
+def t0_filter(rise, fall):
 
     """
-    This processor applies modified assymetric trap filter to the waveform for use in t0 estimation. 
-    It is composed of a factory function that is called using the init_args argument and the function the waveforms are passed to using args.
-    Initialisation Parameters
-    ------------------------
-    rise : int 
-            Specifies length of rise section. This is the linearly increasing section of the filter that performs a weighted average.
-    fall : int
-            Specifies length of fall section. This is the simple averaging part of the filter.
+    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.
+
+    Initialization 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.
+
     Parameters
     ----------
     w_in : array-like
-            waveform to be convolved with t0 filter
-    w_out : array-like
-        waveform convolved with t0 filter
+           The input waveform
+    w_out: array-like
+           The filtered waveform
+
     Processing Chain Example
     ------------------------
     "wf_t0_filter": {
         "function": "t0_filter",
         "module": "pygama.dsp.processors",
-        "args": [ "wf_pz", "wf_t0_filter(3748,f)" ],
-        "init_args" : ["128*ns", "2*us"],
+        "args": ["wf_pz", "wf_t0_filter(3748,f)"],
+        "unit": "ADC",
         "prereqs": ["wf_pz"],
-        "unit": "ADC"
-        },
+        "init_args": ["128*ns", "2*us"]
+    }
     """
+    if rise < 0:
+        raise DSPFatal('The length of the rise section must be positive')
 
-    if (not rise >= 0):
-        raise DSPFatal('rise out of range, must be >= 0')
-    if (not fall >= 0):
-        raise DSPFatal('fall out of range, must be >= 0')
+    if fall < 0:
+        raise DSPFatal('The length of the fall section must be positive')
 
-    t0_kern = np.arange(2/float(rise),0, -2/(float(rise)**2))
-    t0_kern = np.append(t0_kern, np.zeros(int(fall))-(1/float(fall)))
+    t0_kern = np.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):
-
+    def t0_filter_out(w_in, w_out):
         w_out[:] = np.nan
 
-        if (np.isnan(w_in).any()):
+        if np.isnan(w_in).any():
             return
 
-        if (len(t0_kern)> len(w_in)):
-            raise DSPFatal('Filter longer than input waveform')
+        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/pygama/dsp/_processors/fftw.py b/pygama/dsp/_processors/fftw.py
index 4d08d514d..b8c908b3f 100644
--- a/pygama/dsp/_processors/fftw.py
+++ b/pygama/dsp/_processors/fftw.py
@@ -1,30 +1,29 @@
+import numpy as np
 from numba import guvectorize
 from pyfftw import FFTW
-import numpy as np
 
 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
+    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
+    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 raw_to_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)
+    buffer directly---with raw_to_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:
+        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)
@@ -43,27 +42,26 @@ def dft(wf_in, dft_out):
 
 def inv_dft(buf_in, buf_out):
     """
-    Perform inverse discrete fourier transforms using FFTW. 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
+    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
+    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 raw_to_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)
+    buffer directly---with raw_to_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:
+        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)
@@ -80,34 +78,32 @@ def inv_dft(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 raw_to_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)
+    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 raw_to_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)))
+    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:
+        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)
diff --git a/pygama/dsp/_processors/fixed_time_pickoff.py b/pygama/dsp/_processors/fixed_time_pickoff.py
index 85c6917b8..dec3e4e3e 100644
--- a/pygama/dsp/_processors/fixed_time_pickoff.py
+++ b/pygama/dsp/_processors/fixed_time_pickoff.py
@@ -1,41 +1,43 @@
 import numpy as np
 from numba import guvectorize
-import math
 from pygama.dsp.errors import DSPFatal
 
-
 @guvectorize(["void(float32[:], float32, float32[:])",
-              "void(float64[:], float32, float64[:])"],
+              "void(float64[:], float64, float64[:])"],
              "(n),()->()", nopython=True, cache=True)
-
 def fixed_time_pickoff(w_in, t_in, a_out):
     """
-    Fixed time pickoff -- gives the waveform value at a fixed time
+    Pick off the waveform value at the provided index.  If the
+    provided index is out of range, return NaN.
+
     Parameters
     ----------
     w_in : array-like
-            Input waveform
-    t_in : float
-            Time point to find value
-    a_out : float
-            Output value
+           The input waveform
+    t_in : int
+           The waveform index to pick off
+    a_out: float
+           The output pick-off value
+
     Processing Chain Example
     ------------------------
     "trapEftp": {
         "function": "fixed_time_pickoff",
         "module": "pygama.dsp.processors",
-        "args": ["wf_trap", "tp_0 + (10*us+2.5*us)", "trapEftp"],
+        "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)):
+    if np.isnan(w_in).any() or np.isnan(t_in):
         return
-    
-    if (not int(t_in) in range(len(w_in))):
+
+    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/pygama/dsp/_processors/gaussian_filter1d.py b/pygama/dsp/_processors/gaussian_filter1d.py
index 104589d46..c5ec43936 100644
--- a/pygama/dsp/_processors/gaussian_filter1d.py
+++ b/pygama/dsp/_processors/gaussian_filter1d.py
@@ -28,16 +28,15 @@
 # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
-
-# All this code belongs to the team that coded Scipy, found at this link: https://github.com/scipy/scipy/blob/v1.6.0/scipy/ndimage/filters.py#L210-L260 
-# 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
-
+# All this code belongs to the team that coded Scipy, found at this link:
+#     https://github.com/scipy/scipy/blob/v1.6.0/scipy/ndimage/filters.py#L210-L260
+# 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.
 
 import numpy 
 from numba import guvectorize
 
-
 def gaussian_filter1d(sigma, truncate):
     """1-D Gaussian filter.
     Parameters
@@ -57,14 +56,14 @@ def _gaussian_kernel1d(sigma, radius):
         Computes a 1-D Gaussian convolution kernel.
         """
         sigma2 = sigma * sigma
-        x = numpy.arange(-radius, radius+1)
+        x = numpy.arange(-radius, radius + 1)
         phi_x = numpy.exp(-0.5 / sigma2 * x ** 2)
         phi_x = phi_x / phi_x.sum()
         return phi_x
 
     sd = float(sigma)
     
-    # make the radius of the filter equal to truncate standard deviations
+    # Make the radius of the filter equal to truncate standard deviations
     
     lw = int(truncate * sd + 0.5)
     
@@ -73,9 +72,9 @@ def _gaussian_kernel1d(sigma, radius):
     weights = _gaussian_kernel1d(sigma, lw)[::-1]
     weights = numpy.asarray(weights, dtype=numpy.float64)
 
-    # find the length of the kernel so we can reflect the signal an appropriate amount
+    # Find the length of the kernel so we can reflect the signal an appropriate amount
     
-    extension_length = int(len(weights)/2)+1
+    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
@@ -92,7 +91,7 @@ def _gaussian_kernel1d(sigma, radius):
                   "void(int32[:], int32[:])",
                   "void(int64[:], int64[:])"],
                  "(n),(m)", forceobj=True)
-    def gaussian_filter1d_out(wf_in,wf_out):
+    def gaussian_filter1d_out(wf_in, wf_out):
 
         # Have to create an array to enable the reflect mode
         # Extend the signal on the left and right by at least half of the length of the kernel
@@ -101,28 +100,27 @@ 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")
+        if len(wf_in) < extension_length:
+            print('Kernel calculated was larger than signal, try again with smaller parameters')
             return 0
         
-
-        # This mode extends as a reflection 
-        #‘reflect’ (d c b a | a b c d | d c b a)
-        #The input is extended by reflecting about the edge of the last pixel. This mode is also sometimes referred to as half-sample symmetric.
+        # This mode extends as a reflection
+        # ‘reflect’ (d c b a | a b c d | d c b a)
+        # The input is extended by reflecting about the edge of the last pixel. This mode is also
+        # 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
+        # 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
+        # Now extract the original signal length
         
         wf_out[:] = output[extension_length:-extension_length]
         
     return gaussian_filter1d_out
-
diff --git a/pygama/dsp/_processors/get_multi_local_extrema.py b/pygama/dsp/_processors/get_multi_local_extrema.py
new file mode 100644
index 000000000..c6ac057d6
--- /dev/null
+++ b/pygama/dsp/_processors/get_multi_local_extrema.py
@@ -0,0 +1,93 @@
+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
+    Returns
+    -------
+    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/pygama/dsp/_processors/linear_slope_fit.py b/pygama/dsp/_processors/linear_slope_fit.py
index ee56233d1..e39ead2c5 100644
--- a/pygama/dsp/_processors/linear_slope_fit.py
+++ b/pygama/dsp/_processors/linear_slope_fit.py
@@ -5,54 +5,60 @@
 @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):   
     """
-    Calculate mean and standard deviation using Welford's method. In addition, it performs a linear regression and return best fit values for slope and intercept. Note: mean and stdev are computes assuming a flat slope.
+    Calculate the mean and standard deviation of the waveform using
+    Welford's method as well as the slope an intercept of the waveform
+    using linear regression.
     
     Parameters
     ----------
-    w_in : array-like
-            Input waveform 
-    
-    a_mean : float
-    stdev : float
-    slope : float
-    intercept : float
+    w_in     : array-like
+               The input waveform
+    a_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
+
     Processing Chain Example
     ------------------------
-    
-    "bl_mean , bl_std, bl_slope, bl_intercept":{
+    "bl_mean, bl_std, bl_slope, bl_intercept": {
         "function": "linear_slope_fit",
         "module": "pygama.dsp.processors",
-        "args" : ["wf_blsub[0: 1650]", "bl_mean","bl_std", "bl_slope","bl_intercept"],
-        "prereqs": ["wf_blsub"],
-        "unit": ["ADC","ADC","ADC","ADC"]
-        },
+        "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
+    intercept[0] = np.nan
 
-    mean[0] = stdev[0] = slope[0] = intercept[0] = np.nan
-
-    if (np.isnan(w_in).any()):
+    if np.isnan(w_in).any():
         return
 
     sum_x = sum_x2 = sum_xy = sum_y = mean[0] = stdev[0] = 0
     isum = len(w_in)
 
-    for i in range(len(w_in)):
-        # mean and stdev
-        tmp = w_in[i]-mean
-        mean += tmp / (i+1)
-        stdev += tmp*(w_in[i]-mean)
+    for i in range(0, len(w_in), 1):
+        # the mean and standard deviation
+        temp   = w_in[i] - mean
+        mean  += temp / (i + 1)
+        stdev += temp * (w_in[i] - mean)
 
         # linear regression
-        sum_x += i 
-        sum_x2 += i*i
+        sum_x  += i
+        sum_x2 += i * i
         sum_xy += (w_in[i] * i)
-        sum_y += w_in[i]
+        sum_y  += w_in[i]
 
     stdev /= (isum - 1)
     np.sqrt(stdev, stdev)
 
-    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
+    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/pygama/dsp/_processors/log_check.py b/pygama/dsp/_processors/log_check.py
index 5973f80cd..24453e000 100644
--- a/pygama/dsp/_processors/log_check.py
+++ b/pygama/dsp/_processors/log_check.py
@@ -1,35 +1,38 @@
 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):
     """
-    This processor takes in a waveform slice and outputs its logarithm if all the values are positive otherwise returns nan.
-    Typically used to log the decay tail before applying a linear fit to find the pole zero constant.
+    Calculate the logarithm of the waveform if all its values
+    are positive; otherwise, return NaN.
+
     Parameters
     ----------
     w_in : array-like
-           input waveform slice or whole waveform
-    
-    w_log : array-like
-            the output of the processor the logged waveform
+           The input waveform
+    w_log: array-like
+           The output waveform with logged values
+
     Processing Chain Example
     ------------------------
-    "wf_logged":{
+    "wf_logged": {
         "function": "log_check",
         "module": "pygama.dsp.processors",
         "args": ["wf_blsub[2100:]", "wf_logged"],
-        "prereqs": ["wf_blsub"],
-        "unit": "ADC"
-        },
+        "unit": "ADC",
+        "prereqs": ["wf_blsub"]
+    }
     """
-    
     w_log[:] = np.nan
     
-    if (np.isnan(w_in).any() or np.any(w_in<=0)):
+    if np.isnan(w_in).any():
+        return
+
+    if np.any(w_in <= 0):
         return
         
     w_log[:] = np.log(w_in[:])
diff --git a/pygama/dsp/_processors/min_max.py b/pygama/dsp/_processors/min_max.py
index b64c3f938..b83e56c4f 100644
--- a/pygama/dsp/_processors/min_max.py
+++ b/pygama/dsp/_processors/min_max.py
@@ -1,50 +1,51 @@
 import numpy as np
 from numba import guvectorize
+from pygama.dsp.errors import DSPFatal
 
 @guvectorize(["void(float32[:], float32[:], float32[:], float32[:], float32[:])",
-              "void(float64[:], float32[:], float32[:], float64[:], float64[:])"],
+              "void(float64[:], float64[:], float64[:], float64[:], float64[:])"],
              "(n)->(),(),(),()", nopython=True, cache=True)
-
 def min_max(w_in, t_min, t_max, a_min, a_max):
     """
-    Finds the min, max and their time position for a waveform.  If there are
-    multiple samples with the same min/max value, the first one is returned
+    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
-           input waveform
-    
-    t_min : float
-            Output time when waveform is at minimum
-    
-    t_max : float
-            Output time when waveform is at maximum
-    a_min : float
-            Output value when waveform is at minimum
-    
-    a_max : float
-            Output value when waveform is at maximum
+           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
+
     Processing Chain Example
     ------------------------
-    
-    "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"],
-        "prereqs":["waveform"]
-        },
+        "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
 
-    a_min[0] = a_max[0] = t_min[0] = t_max[0] = np.nan
-
-    if (np.isnan(w_in).any()):
+    if np.isnan(w_in).any():
         return
 
     min_index = 0
     max_index = 0
 
-    for i in range(len(w_in)):
+    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]:
diff --git a/pygama/dsp/_processors/moving_windows.py b/pygama/dsp/_processors/moving_windows.py
index 6b6c23348..658060d3c 100644
--- a/pygama/dsp/_processors/moving_windows.py
+++ b/pygama/dsp/_processors/moving_windows.py
@@ -2,171 +2,171 @@
 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):
+    """
+    Apply a moving-average window to the waveform from the left.
 
-    '''
-    Applies a moving average window to the waveform from the left, assumes that the baseline is at 0. 
     Parameters
     ----------
-    w_in : array-like
+    w_in  : array-like
             The input waveform
-    length : float
-              Length of the moving window to be applied
+    length: int
+            The length of the moving window
     w_out : array-like
-            Output waveform after moving window applied
+            The windowed waveform
+
     Processing Chain Example
     ------------------------
-    "wf_mw":{
+    "wf_mw": {
         "function": "moving_window_left",
         "module": "pygama.dsp.processors",
-        "args" : ["wf_pz", "96*ns","wf_mw"],
-        "prereqs": ["wf_pz"],
-        "unit":"ADC"
-        },
-    '''
-    
+        "args": ["wf_pz", "96*ns", "wf_mw"],
+        "unit": "ADC",
+        "prereqs": ["wf_pz"]
+    }
+    """
     w_out[:] = np.nan
 
-    if (np.isnan(w_in).any()):
+    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')
+    if np.floor(length) != length:
+        raise DSPFatal('The length of the moving window 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')
 
-    w_out[0]= w_in[0]/length
+    w_out[0] = w_in[0] / length
     for i in range(1, int(length)):
-        w_out[i] = w_out[i-1] + w_in[i]/length
+        w_out[i] = w_out[i-1] + w_in[i] / 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
-
+        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):
+    """
+    Apply a moving-average window to the waveform from the left.
 
-    '''
-    Applies a moving average window to the waveform from the right. 
     Parameters
     ----------
-    w_in : array-like
+    w_in  : array-like
             The input waveform
-    length : float
-              Length of the moving window to be applied
+    length: int
+            The length of the moving window
     w_out : array-like
-            Output waveform after moving window applied
+            The windowed waveform
+
     Processing Chain Example
     ------------------------
-    "wf_mw":{
+    "wf_mw": {
         "function": "moving_window_right",
         "module": "pygama.dsp.processors",
-        "args" : ["wf_pz", "96*ns","wf_mw"],
-        "prereqs": ["wf_pz"],
-        "unit":"ADC"
-        },
-    '''
-
+        "args": ["wf_pz", "96*ns", "wf_mw"],
+        "unit": "ADC",
+        "prereqs": ["wf_pz"]
+    }
+    """
     w_out[:] = np.nan
 
-    if (np.isnan(w_in).any()):
+    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')
-
+    if np.floor(length) != length:
+        raise DSPFatal('The length of the moving window must be an integer')
 
-    w_out[-1]= w_in[-1] 
-    for i in range(len(w_in)-2, len(w_in)-int(length)-1,-1):
-        w_out[i] = w_out[i+1] + (w_in[i]-w_out[-1])/length
-    for i in range(len(w_in)-int(length)-1, -1, -1):
-        w_out[i] = w_out[i+1] + (w_in[i] - w_in[i+int(length)])/length
+    if int(length) < 0 or int(length) >= len(w_in):
+        raise DSPFatal('The length of the moving window is out of range')
 
+    w_out[-1] = w_in[-1]
+    for i in range(len(w_in) - 2, len(w_in) - int(length) - 1, -1):
+        w_out[i] = w_out[i+1] + (w_in[i] - w_out[-1]) / length
+    for i in range(len(w_in) - int(length) - 1, -1, -1):
+        w_out[i] = w_out[i+1] + (w_in[i] - w_in[i+int(length)]) / length
 
 @guvectorize(["void(float32[:], float32, float32, float32[:])",
               "void(float64[:], float64, float64, float64[:])"],
              "(n),(),()->(n)", nopython=True, cache=True)
-
 def moving_window_multi(w_in, length, num_mw, w_out):
+    """
+    Apply a series of moving-average windows to the waveform, alternating
+    its application between the left and the right.
 
-    '''
-    Applies a series of moving average window to the waveform alternating between applying form the left and right. 
     Parameters
     ----------
-    w_in : array-like
+    w_in  : array-like
             The input waveform
-    length : float
-              Length of the moving window to be applied
-    num_mw : float
-              Number of moving windows to be applied
+    length: int
+            The length of the moving window
+    num_mw: int
+            The number of moving windows
     w_out : array-like
-            Output waveform after moving window applied
+            The windowed waveform
+
     Processing Chain Example
-    ------------------------   
-    
-    "curr_av":{
+    ------------------------
+    "curr_av": {
         "function": "moving_window_multi",
         "module": "pygama.dsp.processors",
-        "args" : ["curr", "96*ns", 3,"curr_av"],
-        "prereqs": ["curr"],
-        "unit":"ADC/sample"
-        },
-    '''
-
+        "args": ["curr", "96*ns", "3", "curr_av"],
+        "unit": "ADC/sample",
+        "prereqs": ["curr"]
+    }
+    """
     w_out[:] = np.nan
 
-    if (np.isnan(w_in).any()):
+    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')
-    
-    if (not num_mw >= 0) :
-        raise DSPFatal('num_mw is out of range, must be >= 0')
+    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')
 
     wf_buf = w_in.copy()
-    for i in range(num_mw):
-        
+    for i in range(0, int(num_mw), 1):
         if i % 2 == 1:
-            w_out[-1]= w_in[-1] 
-            for i in range(len(w_in)-2, len(w_in)-int(length)-1,-1):
-                w_out[i] = w_out[i+1] + (w_in[i]-w_out[-1])/length
-            for i in range(len(wf_buf)-(int(length)+1), -1,-1):
-                w_out[i] = w_out[i+1] + (wf_buf[i] - wf_buf[i+int(length)])/length
+            w_out[-1] = w_in[-1]
+            for i in range(len(w_in) - 2, len(w_in) - int(length) - 1, -1):
+                w_out[i] = w_out[i+1] + (w_in[i] - w_out[-1]) / length
+            for i in range(len(wf_buf) - int(length) - 1, -1, -1):
+                w_out[i] = w_out[i+1] + (wf_buf[i] - wf_buf[i+int(length)]) / length
         else:
-            w_out[0]= wf_buf[0]/length
-            for i in range(1, int(length)):
-                w_out[i] = w_out[i-1] + wf_buf[i]/length
-            for i in range(int(length), len(w_in)):
-                w_out[i] = w_out[i-1] + (wf_buf[i] - wf_buf[i-int(length)])/length
+            w_out[0] = wf_buf[0] / length
+            for i in range(1, int(length), 1):
+                w_out[i] = w_out[i-1] + wf_buf[i] / length
+            for i in range(int(length), len(w_in), 1):
+                w_out[i] = w_out[i-1] + (wf_buf[i] - wf_buf[i-int(length)]) / length
         wf_buf[:] = w_out[:]
 
-
-
 @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
+    Calculate the derivative of the waveform, averaged across the specified
+    number of samples.
+
     Parameters
     ----------
-    w_in : array-like
+    w_in  : array-like
             The input waveform
-    length : float
-              Length of the moving window derivative to be applied
+    length: int
+            The length of the moving window
     w_out : array-like
-            Output waveform after derivation
+            The output derivative
+
     Processing Chain Example
     ------------------------
     "curr": {
@@ -175,17 +175,18 @@ def avg_current(w_in, length, w_out):
         "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()):
+    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')
-    
+    if np.floor(length) != length:
+        raise DSPFatal('The length of the moving window 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')
 
     w_out[:] = w_in[int(length):] - w_in[:-int(length)]
-    w_out/=length
+    w_out /= length
diff --git a/pygama/dsp/_processors/multi_a_filter.py b/pygama/dsp/_processors/multi_a_filter.py
new file mode 100644
index 000000000..ee487a0c7
--- /dev/null
+++ b/pygama/dsp/_processors/multi_a_filter.py
@@ -0,0 +1,37 @@
+import numpy as np
+from numba import guvectorize
+from pygama.dsp.errors import DSPFatal
+from pygama.dsp._processors.fixed_time_pickoff import fixed_time_pickoff
+
+@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
+    
+    Parameters
+    ----------
+    w_in : array-like
+        The array of data within which amplitudes of extrema will be found 
+    vt_maxs_in : array-like
+        The array of max positions for each wf
+    Returns
+    -------
+    va_max_out: array-like
+        An array of the amplitudes of the maximums of the waveform
+    """
+    
+    # Initialize output parameters 
+    
+    va_max_out[:] = np.nan
+    
+    # Check inputs 
+    
+    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')
+    
+    fixed_time_pickoff(w_in, vt_maxs_in, va_max_out)
\ No newline at end of file
diff --git a/pygama/dsp/_processors/multi_t_filter.py b/pygama/dsp/_processors/multi_t_filter.py
new file mode 100644
index 000000000..03d852f26
--- /dev/null
+++ b/pygama/dsp/_processors/multi_t_filter.py
@@ -0,0 +1,101 @@
+import numpy as np
+from numba import guvectorize
+from pygama.dsp._processors.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, 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 indicies 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.
+    ----------
+    t_in : array-like
+        The array of indices that we want to remove duplicates from 
+    vt_min_in : array-like
+        List of indicies 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_maxs_in : array-like
+        The array of max positions for each wf
+    vt_mins_in : array-like
+        The array of min positions for each wf
+    Returns
+    -------
+    t_out : array-like
+        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)
\ No newline at end of file
diff --git a/pygama/dsp/_processors/optimize.py b/pygama/dsp/_processors/optimize.py
new file mode 100644
index 000000000..62cf67e0e
--- /dev/null
+++ b/pygama/dsp/_processors/optimize.py
@@ -0,0 +1,148 @@
+import numpy as np
+from numba import guvectorize
+from pygama.dsp.errors import DSPFatal
+from .pole_zero import pole_zero, double_pole_zero
+from iminuit import Minuit
+
+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
+              
+    Processing Chain Example
+    ------------------------
+    "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
+              
+    Processing Chain Example
+    ------------------------
+    "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/pygama/dsp/_processors/param_lookup.py b/pygama/dsp/_processors/param_lookup.py
index a0fe35cc7..a5e6bc397 100644
--- a/pygama/dsp/_processors/param_lookup.py
+++ b/pygama/dsp/_processors/param_lookup.py
@@ -3,20 +3,23 @@
 
 def param_lookup(param_dict, default_val, dtype):
     """
-    Generate the ufunc lookup(channel, val), which returns a numpy array of
+    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.
+    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() }
+
+    # 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)
+    @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"""
+        """
+        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/pygama/dsp/_processors/pole_zero.py b/pygama/dsp/_processors/pole_zero.py
index c5b6da83a..4d6e5b617 100644
--- a/pygama/dsp/_processors/pole_zero.py
+++ b/pygama/dsp/_processors/pole_zero.py
@@ -5,90 +5,84 @@
 @guvectorize(["void(float32[:], float32, float32[:])",
               "void(float64[:], float64, float64[:])"],
              "(n),()->(n)", nopython=True, cache=True)
-
 def pole_zero(w_in, t_tau, w_out):
     """
-    Applies a Pole-zero correction using time constant tau
+    Apply a pole-zero cancellation using the provided time
+    constant to the waveform.
+
     Parameters
     ----------
     w_in : array-like
-           waveform to apply pole zero correction to. Needs to be baseline subtracted
-    
-    t_tau : float
-            Time constant of exponential decay to be deconvolved
-    
-    w_out : array-like
-            Output array for pole zero corrected waveform 
+           The input waveform
+    t_tau: float
+           The time constant of the exponential to be deconvolved
+    w_out: array-like
+           The pole-zero cancelled waveform
+
     Processing Chain Example
     ------------------------
     "wf_pz": {
         "function": "pole_zero",
         "module": "pygama.dsp.processors",
-        "args": ["wf_blsub", "db.pz.tau", "wf_pz"],
-        "prereqs": ["wf_blsub"],
+        "args": ["wf_bl", "400*us", "wf_pz"],
         "unit": "ADC",
-        "defaults": { "db.pz.tau":"74*us" }
-        },
+        "prereqs": ["wf_bl"]
+    }
     """
-
     w_out[:] = np.nan 
 
-    if (np.isnan(w_in).any() or np.isnan(t_tau)):
+    if np.isnan(w_in).any() or np.isnan(t_tau):
         return
 
-    const = np.exp(-1/t_tau)
+    const = np.exp(-1 / t_tau)
     w_out[0] = w_in[0]
-    for i in range(1, len(w_in)):
+    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):
     """
-    Pole-zero correction using two time constants: one main (long) time constant
-    tau1, and a shorter time constant tau2 that contributes a fraction frac
+    Apply a double pole-zero cancellation using the provided time
+    constant to the waveform.
+
     Parameters
     ----------
-    w_in : array-like
-           waveform to apply pole zero correction to. Needs to be baseline subtracted
-    
-    t_tau1 : float
-             Time constant of first exponential decay to be deconvolved
-    t_tau2 : float
-             Time constant of second exponential decay to be deconvolved
-    frac : float
-           Fraction which tau2 contributes to decay
-    
+    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
-            Output array for pole zero corrected waveform 
+            The pole-zero cancelled waveform
+
     Processing Chain Example
     ------------------------
-    "wf_pz2": {
+    "wf_pz": {
         "function": "double_pole_zero",
         "module": "pygama.dsp.processors",
-        "args": ["wf_blsub", "db.pz2.tau1", "db.pz2.tau2",  "db.pz2.frac", "wf_pz2"],
-        "prereqs": ["wf_blsub"],
+        "args": ["wf_bl", "400*us", "20*us", "0.02", "wf_pz"],
         "unit": "ADC",
-        "defaults": { "db.pz2.tau1":"74*us", "db.pz2.tau2":"3*us", "db.pz2.frac":"0.013" }
-        },
+        "prereqs": ["wf_bl"]
+    }
     """
-    
     w_out[:] = np.nan 
 
-    if (np.isnan(w_in).any() or np.isnan(t_tau1) or np.isnan(t_tau2) or np.isnan(frac)):
+    if np.isnan(w_in).any() or np.isnan(t_tau1) or np.isnan(t_tau2) or np.isnan(frac):
         return
 
-    const1 = 1/t_tau1 #np.exp(-1/t_tau1)
-    const2 = 1/t_tau2 #np.exp(-1/t_tau2)
+    const1 = 1 / t_tau1
+    const2 = 1 / t_tau2
     w_out[0] = w_in[0]
     e1 = w_in[0]
     e2 = w_in[0]
     e3 = 0
-    for i in range(1, len(w_in)):
-        e1 += w_in[i] - e2 + e2*const1
-        e3 += w_in[i] - e2 - e3*const2
-        e2 = w_in[i]
-        w_out[i] = e1 - frac*e3
+    for i in range(1, len(w_in), 1):
+        e1 += w_in[i] - e2 + e2 * const1
+        e3 += w_in[i] - e2 - e3 * const2
+        e2  = w_in[i]
+        w_out[i] = e1 - frac * e3
diff --git a/pygama/dsp/_processors/presum.py b/pygama/dsp/_processors/presum.py
index 26fd3b50b..f67b2faa9 100644
--- a/pygama/dsp/_processors/presum.py
+++ b/pygama/dsp/_processors/presum.py
@@ -1,15 +1,31 @@
+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(wf_in, wf_out):
-    """Presum the waveform. Combine bins in chunks of len(wf_in)/len(wf_out),
-    which is hopefully an integer. If it isn't, then some samples at the end
-    will be omitted"""
-    ps_fact = len(wf_in)//len(wf_out)
-    for i in range(0, len(wf_out)):
-        j0 = i*ps_fact
-        wf_out[i] = wf_in[j0]/ps_fact
-        for j in range(j0+1, j0+ps_fact):
-            wf_out[i] += wf_in[j]/ps_fact
+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/pygama/dsp/_processors/saturation.py b/pygama/dsp/_processors/saturation.py
new file mode 100644
index 000000000..5251c0dd5
--- /dev/null
+++ b/pygama/dsp/_processors/saturation.py
@@ -0,0 +1,53 @@
+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
+    
+    Processing Chain Example
+    ------------------------
+    "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/pygama/dsp/_processors/soft_pileup_corr.py b/pygama/dsp/_processors/soft_pileup_corr.py
new file mode 100644
index 000000000..099e74d15
--- /dev/null
+++ b/pygama/dsp/_processors/soft_pileup_corr.py
@@ -0,0 +1,122 @@
+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
+    
+    Processing Chain Example
+    ------------------------
+    "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
+    
+    Processing Chain Example
+    ------------------------
+    "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/pygama/dsp/_processors/template.py b/pygama/dsp/_processors/template.py
index fc587c40a..fe7030500 100644
--- a/pygama/dsp/_processors/template.py
+++ b/pygama/dsp/_processors/template.py
@@ -1,19 +1,19 @@
-# 1) Import python modules
+# 1) Import Python modules
 #
 import numpy as np
 from numba import guvectorize
 from pygama.dsp.errors import DSPFatal
 
-# 2) Provide instructions to numba
+# 2) Provide instructions to Numba
 # 
-# Documentation about numba guvectorize decorator:
+# Documentation about Numba guvectorize decorator:
 # https://numba.pydata.org/numba-doc/latest/user/vectorize.html#the-guvectorize-decorator
 #
 # Notes:
-# - use `nopython=True` and `cache=True`
-# - use two declarations, one for 32-bit variables and one for 64-bit variables
-# - do not use ints as they do not support NaN values.
-# - use [:] for all output parameters
+# - Use "nopython=True, cache=True"
+# - Use two declarations, one for 32-bit variables and one for 64-bit variables
+# - Do not use "int" as it does not support an NaN value
+# - Use [:] for all output parameters
 #
 @guvectorize(["void(float32[:], float32, float32, float32[:])",
               "void(float64[:], float64, float64, float64[:])"],
@@ -21,11 +21,11 @@
 
 # 3) Define the processor interface
 #
-# Notes: (@all, this is up for discussion)
-# - add the `_in`/`_out` suffix to the name of the input/output variables
-# - 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
+# Notes:
+# - Add the "_in"/"_out" suffix to the name of the input/output variables
+# - 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"
 #
 def the_processor_template(w_in, t_in, a_in, w_out, t_out):
 
@@ -36,45 +36,45 @@ def the_processor_template(w_in, t_in, a_in, w_out, t_out):
     meaning of input and output variables
     """
 
-    # 5) Initialize output parameters (@iann, will this be done automatically by the processing chain?)
+    # 5) Initialize output parameters
     #
     # Notes:
-    # - all output parameters should be initializes to a not-a-number. If a
-    #   processor fails, its output parameters should have the default nan value
-    # - use np.nan for both variables and arrays
+    # - All output parameters should be initializes to a NaN.  If a processor
+    #   fails, its output parameters should have the default NaN value
+    # - Use np.nan for both variables and arrays
     #
     w_out[:] = np.nan # use [:] for arrays
     t_out[0] = np.nan # use [0] for scalar parameters
 
-    # 6) Check inputs. 
+    # 6) Check inputs
     #
     # There are two kinds of checks:
-    # - NaN checks. A processor might depend on others, i.e. its input
-    #   parameters are the output parameters of an other processors. When a
+    # - NaN checks.  A processor might depend on others, i.e., its input
+    #   parameters are the output parameters of an other processors.  When a
     #   processor fails, all processors depending on its output should not run.
     #   Thus, skip this processor if a NaN value is detected and return NaN
     #   output parameters to propagate the failure throughout the processing chain.
-    # - in range checks. Check if indexes are within 0 and len(waveform),
-    #   amplitudes are positive, etc. A failure of this check implies errors in
-    #   the dsp json config file. Abort the analysis immediately.
+    # - In-range checks.  Check if indexes are within 0 and len(waveform),
+    #   amplitudes are positive, etc.  A failure of this check implies errors in
+    #   the DSP JSON config file.  Abort the analysis immediately.
     #
-    if (np.isnan(w_in).any() or np.isnan(t_in) or np.isnan(a_in)):
+    if np.isnan(w_in).any() or np.isnan(t_in) or np.isnan(a_in):
         return
 
-    if (not t_in in range(len(w_in)) or not a_in >= 0):
-        raise DSPFatal('Error Message e.g. t_in not in range must be within length of waveform')
+    if a_in < 0:
+        raise DSPFatal('The error message goes here')
 
     # 7) Algorithm
     # 
-    # Loop over waveforms by using a `for i in range(.., .., ..)` instruction. 
-    # Avoid loops based on a while condition which might lead to segfault. Avoid
+    # Loop over waveforms by using a "for i in range(.., .., ..)" instruction.
+    # Avoid loops based on a while condition which might lead to segfault.  Avoid
     # also enumerate/ndenumerate to keep code as similar as possible among all
     # processors.
     #
-    # Example of an algorithm to find the first index above t_in in which the
-    # signal crossed the value a_in
+    # Example of an algorithm to find the first index above "t_in" in which the
+    # signal crossed the value "a_in"
     #
     for i in range(t_in, 1, 1):
-        if( w_in[i] >= a_in and w_in[i-1] < a_in):
+        if w_in[i] >= a_in and w_in[i-1] < a_in:
             t_out[0] = i
             return
diff --git a/pygama/dsp/_processors/time_point_thresh.py b/pygama/dsp/_processors/time_point_thresh.py
index e28036677..9a24406c7 100644
--- a/pygama/dsp/_processors/time_point_thresh.py
+++ b/pygama/dsp/_processors/time_point_thresh.py
@@ -1,59 +1,59 @@
 import numpy as np
 from numba import guvectorize
-import math
 from pygama.dsp.errors import DSPFatal
 
-@guvectorize(["void(float32[:], float32, float64, int32 ,float32[:])",
-              "void(float64[:], float64, float64, int32 ,float32[:])"],
+@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 time after/before t_start where w_in first crosses a threshold 
+    Find the index where the waveform value crosses the threshold,
+    walking either forward or backward from the starting index.
+
     Parameters
     ----------
-     w_in: array-like
-           Input waveform
-    
-     a_threshold: float
-                  Threshold to search for
-     t_start: float
-               Start point to walk forwards from 
-     walk_forward: int
-                   Define search direction 1 is forward, 0 is back
-    
-     tp_out: float
-             Final time that waveform is less than threshold
+    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
+
     Processing Chain Example
     ------------------------
-    
     "tp_0": {
         "function": "time_point_thresh",
         "module": "pygama.dsp.processors",
-        "args": [ "wf_atrap", "bl_std", "tp_start", 0 ,"tp_0" ],
+        "args": ["wf_atrap", "bl_std", "tp_start", 0, "tp_0"],
         "unit": "ns",
-        "prereqs": [ "wf_atrap", "bl_std", "tp_start" ]
-        },
+        "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)):
+    if np.isnan(w_in).any() or np.isnan(a_threshold) or np.isnan(t_start) or np.isnan(walk_forward):
         return
     
-    if (not np.floor(t_start)==t_start):
-        raise DSPFatal('Start time is not an integer')
+    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 (not int(t_start) in range(len(w_in))):
-        raise DSPFatal('Time point not in length of waveform')
+    if int(t_start) < 0 or int(t_start) >= len(w_in):
+        raise DSPFatal('The starting index is out of range')
 
-    if(walk_forward == 1):
-        for i in range(int(t_start), len(w_in)-1):
-            if(w_in[i] <= a_threshold < w_in[i+1]):
+    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]):
+            if w_in[i-1] < a_threshold <= w_in[i]:
                 t_out[0] = i
                 return
diff --git a/pygama/dsp/_processors/trap_filters.py b/pygama/dsp/_processors/trap_filters.py
index 0239ac6f8..648ea373f 100644
--- a/pygama/dsp/_processors/trap_filters.py
+++ b/pygama/dsp/_processors/trap_filters.py
@@ -1,246 +1,264 @@
 import numpy as np
 from numba import guvectorize
-import math
 from pygama.dsp.errors import DSPFatal
 
-
 @guvectorize(["void(float32[:], float32, float32, float32[:])",
-              "void(float64[:], float32, float32, float64[:])"],
+              "void(float64[:], float64, float64, float64[:])"],
              "(n),(),()->(n)", nopython=True, cache=True)
 def trap_filter(w_in, rise, flat, w_out):
-    
     """
-    Applies a symmetric trapezoidal filter (rise= fall) to the waveform 
-    Parameters:
-    -----------
+    Apply a symmetric trapezoidal filter to the waveform.
+
+    Parameters
+    ----------
     w_in : array-like
-           Input Waveform
-    rise : float
-           Sets the number of samples that will be averaged in the rise and fall sections
-    flat : float
-            Controls the delay between the rise and fall averaging sections, 
-            typically around 3us for ICPC energy estimation, lower for detectors with shorter drift times
-    w_out : array-like
-            Output waveform after trap filter applied
+           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
+
     Processing Chain Example
     ------------------------
-    "wf_trap": {
+    "wf_tf": {
         "function": "trap_filter",
         "module": "pygama.dsp.processors",
-        "args": ["wf_pz", "10*us", "3*us", "wf_trap"],
-        "prereqs": ["wf_pz"],
-        "unit": "ADC"
-        },
+        "args": ["wf_pz", "10*us", "3*us", "wf_tf"],
+        "unit": "ADC",
+        "prereqs": ["wf_pz"]
+    }
     """
-    
     w_out[:] = np.nan
 
-    if (np.isnan(w_in).any()):
+    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat):
         return
 
-    if (not  0 <= rise):
-        raise DSPFatal('Rise must be >= 0')
+    if np.floor(rise) != rise:
+        raise DSPFatal('The number of samples in the rise section must be an integer')
+
+    if np.floor(flat) != flat:
+        raise DSPFatal('The number of samples in the flat section must be an integer')
+
+    if int(rise) < 0:
+        raise DSPFatal('The number of samples in the rise section must be positive')
     
-    if (not  0 <= flat):
-        raise DSPFatal('Flat must be >= 0')
+    if int(flat) < 0:
+        raise DSPFatal('The number of samples in the flat section must be positive')
     
-    if (not 2*rise+flat <= len(w_in)):
-        raise DSPFatal('Trap Filter longer than waveform')
+    if 2 * int(rise) + int(flat) > len(w_in):
+        raise DSPFatal('The trapezoid width is wider than the waveform')
     
     rise_int = int(rise)
     flat_int = int(flat)
     w_out[0] = w_in[0]
-    for i in range(1, rise_int):
+    for i in range(1, rise_int, 1):
         w_out[i] = w_out[i-1] + w_in[i]
-    for i in range(rise_int, rise_int+flat_int):
+    for i in range(rise_int, rise_int + flat_int, 1):
         w_out[i] = w_out[i-1] + w_in[i] - w_in[i-rise_int]
-    for i in range(rise_int+flat_int, 2*rise_int+flat_int):
+    for i in range(rise_int + flat_int, 2 * rise_int + flat_int, 1):
         w_out[i] = w_out[i-1] + w_in[i] - w_in[i-rise_int] - w_in[i-rise_int-flat_int]
-    for i in range(2*rise_int+flat_int, len(w_in)):
+    for i in range(2 * rise_int + flat_int, len(w_in), 1):
         w_out[i] = w_out[i-1] + w_in[i] - w_in[i-rise_int] - w_in[i-rise_int-flat_int] + w_in[i-2*rise_int-flat_int]
 
 @guvectorize(["void(float32[:], float32, float32, float32[:])",
-              "void(float64[:], float32, float32, float64[:])"],
+              "void(float64[:], float64, float64, float64[:])"],
              "(n),(),()->(n)", nopython=True, cache=True)
-
 def trap_norm(w_in, rise, flat, w_out):
-   
     """
-    Applies a symmetric trapezoidal filter (rise= fall) to the waveform normalized by integration time
-    Parameters:
-    -----------
+    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
-           Input Waveform
-    rise : float
-           Sets the number of samples that will be averaged in the rise and fall sections
-    flat : float
-            Controls the delay between the rise and fall averaging sections, 
-            typically around 3us for ICPC energy estimation, lower for detectors with shorter drift times
-    w_out : array-like
-            Output waveform after trap filter applied
+           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
+
     Processing Chain Example
     ------------------------
-    "wf_trap": {
+    "wf_tf": {
         "function": "trap_norm",
         "module": "pygama.dsp.processors",
-        "args": ["wf_pz", "10*us", "3*us", "wf_trap"],
-        "prereqs": ["wf_pz"],
-        "unit": "ADC"
-        },
+        "args": ["wf_pz", "10*us", "3*us", "wf_tf"],
+        "unit": "ADC",
+        "prereqs": ["wf_pz"]
+    }
     """
-    
     w_out[:] = np.nan
 
-    if (np.isnan(w_in).any()):
+    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat):
         return
 
-    if (not  0 <= rise):
-        raise DSPFatal('Rise must be >= 0')
-    
-    if (not  0 <= flat):
-        raise DSPFatal('Flat must be >= 0')
-    
-    if (not 2*rise+flat <= len(w_in)):
-        raise DSPFatal('Trap Filter longer than waveform')
+    if np.floor(rise) != rise:
+        raise DSPFatal('The number of samples in the rise section must be an integer')
+
+    if np.floor(flat) != flat:
+        raise DSPFatal('The number of samples in the flat section 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')
     
     rise_int = int(rise)
     flat_int = int(flat)
-    w_out[0] = w_in[0]/float(rise)
-    for i in range(1, rise_int):
-        w_out[i] = w_out[i-1] + w_in[i]/rise
-    for i in range(rise_int, rise_int+flat_int):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int])/rise
-    for i in range(rise_int+flat_int, 2*rise_int+flat_int):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int] - w_in[i-rise_int-flat_int])/rise
-    for i in range(2*rise_int+flat_int, len(w_in)):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int] - w_in[i-rise_int-flat_int] + w_in[i-2*rise_int-flat_int])/rise
+    w_out[0] = w_in[0] / rise
+    for i in range(1, rise_int, 1):
+        w_out[i] = w_out[i-1] + w_in[i] / rise
+    for i in range(rise_int, rise_int + flat_int, 1):
+        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int]) / rise
+    for i in range(rise_int + flat_int, 2 * rise_int + flat_int, 1):
+        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int] - w_in[i-rise_int-flat_int]) / rise
+    for i in range(2 * rise_int + flat_int, len(w_in), 1):
+        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int] - w_in[i-rise_int-flat_int] + w_in[i-2*rise_int-flat_int]) / rise
 
 @guvectorize(["void(float32[:], float32, float32, float32, float32[:])",
-              "void(float64[:], float32, float32, float32, float64[:])"],
+              "void(float64[:], float64, float64, float64, float64[:])"],
              "(n),(),(),()->(n)", nopython=True, cache=True)
-
 def asym_trap_filter(w_in, rise, flat, fall, w_out):
-       
     """
-    Applies asymmetric trapezoidal filter to the waveform normalized by the integration times
-    Parameters:
+    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
-           Input Waveform
-    rise : float
-           Sets the number of samples that will be averaged in the rise section
-    flat : float
-            Controls the delay between the rise and fall averaging sections, 
-            typically around 3us for ICPC energy estimation, lower for detectors with shorter drift times
-    rise : float
-           Sets the number of samples that will be averaged in the fall section
-    w_out : array-like
-            Output waveform after trap filter applied
+           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
+
     Processing Chain Example
     ------------------------
-    "wf_atrap": {
+    "wf_af": {
         "function": "asym_trap_filter",
         "module": "pygama.dsp.processors",
-        "args": ["wf_pz", "128*ns", "64*ns","2*us", "wf_atrap"],
-        "prereqs": ["wf_pz"],
-        "unit": "ADC"
-        },
+        "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()):
+    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat) or np.isnan(fall):
         return
 
-    if (not  0 <= rise):
-        raise DSPFatal('Rise must be >= 0')
-    
-    if (not  0 <= flat):
-        raise DSPFatal('Flat must be >= 0')
+    if np.floor(rise) != rise:
+        raise DSPFatal('The number of samples in the rise section must be an integer')
 
-    if (not  0 <= fall):
-        raise DSPFatal('Fall must be >= 0')
-    
-    if (not rise+flat+fall <= len(w_in)):
-        raise DSPFatal('Trap Filter longer than waveform')
+    if np.floor(flat) != flat:
+        raise DSPFatal('The number of samples in the flat section must be an integer')
+
+    if np.floor(fall) != fall:
+        raise DSPFatal('The number of samples in the fall section 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 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')
 
     rise_int = int(rise)
     flat_int = int(flat)
     fall_int = int(fall)
-    w_out[0] = w_in[0]/float(rise)
-    for i in range(1, rise_int):
-        w_out[i] = w_out[i-1] + w_in[i]/rise
-    for i in range(rise_int, rise_int+flat_int):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int])/rise
-    for i in range(rise_int+flat_int, rise_int+flat_int+fall_int):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int])/rise - w_in[i-rise_int-flat_int]/fall
-    for i in range(rise_int+flat_int+fall_int, len(w_in)):
-        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int])/rise - (w_in[i-rise_int-flat_int] - w_in[i-rise_int-flat_int-fall_int])/fall
+    w_out[0] = w_in[0] / rise
+    for i in range(1, rise_int, 1):
+        w_out[i] = w_out[i-1] + w_in[i] / rise
+    for i in range(rise_int, rise_int + flat_int, 1):
+        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int]) / rise
+    for i in range(rise_int + flat_int, rise_int + flat_int + fall_int, 1):
+        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int]) / rise - w_in[i-rise_int-flat_int] / fall
+    for i in range(rise_int + flat_int + fall_int, len(w_in), 1):
+        w_out[i] = w_out[i-1] + (w_in[i] - w_in[i-rise_int]) / rise - (w_in[i-rise_int-flat_int] - w_in[i-rise_int-flat_int-fall_int]) / fall
 
 @guvectorize(["void(float32[:], float32, float32, float32, float32[:])",
-              "void(float64[:], float32, float32, float32, float64[:])"],
+              "void(float64[:], float64, float64, float64, float64[:])"],
              "(n),(),(),()->()", nopython=True, cache=True)
-
 def trap_pickoff(w_in, rise, flat, t_pickoff, a_out):
     """
-    Gives the value of a trapezoid, normalized by the "rise" (integration)
-    time, at a specific time equal to pickoff_time. Use when the rest of
-    the trapezoid output is extraneous.
+    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:
+    Parameters
     ----------
-    w_in : array-like
-           Input Waveform
-    rise : float
-           Sets the number of samples that will be averaged in the rise section
-    flat : float
-            Controls the delay between the rise and fall averaging sections, 
-            typically around 3us for ICPC energy estimation, lower for detectors with shorter drift times
-    t_pickoff : float
-            Time to take sample
-    a_out : float
-            Output amplitude after trap filter applied
+    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: int
+               The waveform index to pick off
+    a_out    : float
+               The output pick-off value of the filtered waveform
     
     Processing Chain Example
     ------------------------
     "ct_corr": {
         "function": "trap_pickoff",
         "module": "pygama.dsp.processors",
-        "args":["wf_pz", "1.5*us", 0, "tp_0", "ct_corr"],
+        "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()):
+    if np.isnan(w_in).any() or np.isnan(rise) or np.isnan(flat) or np.isnan(t_pickoff):
         return
 
-    if (not  0 <= rise):
-        raise DSPFatal('Rise must be >= 0')
-    
-    if (not  0 <= flat):
-        raise DSPFatal('Flat must be >= 0')
+    if np.floor(rise) != rise:
+        raise DSPFatal('The number of samples in the rise section must be an integer')
+
+    if np.floor(flat) != flat:
+        raise DSPFatal('The number of samples in the flat section must be an integer')
+
+    if np.floor(t_pickoff) != t_pickoff:
+        raise DSPFatal('The pick-off index must be an integer')
 
-    if (not 2*rise+flat <= len(w_in)):
-        raise DSPFatal('Trap Filter longer than waveform')
+    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.
-    rise_int = int(rise)
-    flat_int = int(flat)
-    start_time = int(t_pickoff + 1) # the +1 makes slicing prettier
+    rise_int   = int(rise)
+    flat_int   = int(flat)
+    start_time = int(t_pickoff + 1)
 
     if not len(w_in) >= start_time >= 2*rise_int + flat_int:
         return
     
-    for i in range(start_time-rise_int, start_time):
+    for i in range(start_time - rise_int, start_time, 1):
         I_1 += w_in[i]
-
-    for k in range(start_time - 2*rise_int - flat_int, start_time - rise_int - flat_int):
-        I_2 += w_in[k]
-
-    a_out[0] = (I_1 - I_2)/rise
+    for i in range(start_time - 2 * rise_int - flat_int, start_time - rise_int - flat_int, 1):
+        I_2 += w_in[i]
+    a_out[0] = (I_1 - I_2) / rise
diff --git a/pygama/dsp/_processors/windower.py b/pygama/dsp/_processors/windower.py
index 8ff5ec030..ac7ba3c7f 100644
--- a/pygama/dsp/_processors/windower.py
+++ b/pygama/dsp/_processors/windower.py
@@ -1,40 +1,46 @@
-from numba import guvectorize
 import numpy as np
+from numba import guvectorize
 from pygama.dsp.errors import DSPFatal
 
-@guvectorize(["void(float32[:], float64, float32[:])",
+@guvectorize(["void(float32[:], float32, float32[:])",
               "void(float64[:], float64, float64[:])"],
              "(n),(),(m)", nopython=True, cache=True)
-def windower(wf_in, t0_in, wf_out):
-    """Waveform windower: returns a shorter sample of the waveform
-    starting at t0_in. Note that the length of the output waveform
-    is determined by the length of wf_out rather than an input parameter!
-    If the the length of wf_out plus t0_in extends past the end of wf_in,
-    or if t0_in<0, pad the remaining values with NaN.
+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
     ----------
-    wf_in : array of float32/float64
-            full waveform
-    t0_in : float64
-            start time of window. First index is floor(t0_in).
-    wf_out: array of float32/float64
-            windowed waveform
+    w_in : array-like
+           The input waveform
+    t0_in: int
+           The starting index of the window
+    w_out: array-like
+           The windowed waveform
     """
-    if len(wf_out) >= len(wf_in):
-        raise DSPFatal("Windowed waveform must be smaller than input waveform!")
+    w_out[:] = np.nan
 
-    if np.isnan(t0_in):
-        wf_out[:] = np.nan
+    if np.isnan(w_in).any() or np.isnan(t0_in):
         return
+
+    if np.floor(t0_in) != t0_in:
+        raise DSPFatal('The starting index must be an integer')
+
+    if len(w_out) >= len(w_in):
+        raise DSPFatal('The windowed waveform must be smaller than the input waveform')
     
-    begin = min(np.floor(t0_in), len(wf_in))
-    end = max(begin + len(wf_out), 0)
-    if begin<0:
-        wf_out[:len(wf_out)-end] = np.nan
-        wf_out[len(wf_out)-end:] = wf_in[:end]
-    elif end<len(wf_in):
-        wf_out[:] = wf_in[begin:end]
+    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:
-        wf_out[:len(wf_in)-begin] = wf_in[begin:len(wf_in)]
-        wf_out[len(wf_in)-begin:] = np.nan
+        w_out[:len(w_in)-beg] = w_in[beg:len(w_in)]
+        w_out[len(w_in)-beg:] = np.nan
diff --git a/pygama/dsp/build_dsp.py b/pygama/dsp/build_dsp.py
old mode 100755
new mode 100644
index de3c866d6..373e2c804
--- a/pygama/dsp/build_dsp.py
+++ b/pygama/dsp/build_dsp.py
@@ -1,5 +1,4 @@
-#! /usr/bin/env python3
-
+#!/usr/bin/env python3
 import os
 import sys
 import json
@@ -9,16 +8,31 @@
 import argparse
 from importlib.resources import read_text
 
+<<<<<<< HEAD:pygama/dsp/build_dsp.py
 import pygama
 from pygama import git
 from pygama.dsp.processing_chain import build_processing_chain
+=======
+from pygama import __version__ as pygama_version
+from pygama.dsp.ProcessingChain import ProcessingChain
+from pygama.dsp.units import *
+from pygama import lh5
+from pygama.utils import tqdm_range
+import pygama.git as git
+from pygama.dsp.build_processing_chain import *
+>>>>>>> main:pygama/io/raw_to_dsp.py
 from pygama.dsp.errors import DSPFatal
 import pygama.lgdo.lh5_store as lh5
 from pygama.math.utils import update_progress
 
 def raw_to_dsp(f_raw, f_dsp, dsp_config, lh5_tables=None, database=None,
+<<<<<<< HEAD:pygama/dsp/build_dsp.py
                outputs=None, n_max=np.inf, write_mode='r', buffer_len=3200,
                block_width=16, verbose=1):
+=======
+               outputs=None, n_max=np.inf, overwrite=True, buffer_len=3200,
+               block_width=16, verbose=1, chan_config=None):
+>>>>>>> main:pygama/io/raw_to_dsp.py
     """
     Convert raw-tier LH5 data into dsp-tier LH5 data by running a sequence of
     processors via the ProcessingChain.
@@ -55,6 +69,7 @@ def raw_to_dsp(f_raw, f_dsp, dsp_config, lh5_tables=None, database=None,
     """
     t_start = time.time()
 
+<<<<<<< HEAD:pygama/dsp/build_dsp.py
     if isinstance(dsp_config, str):
         with open(dsp_config, 'r') as config_file:
             dsp_config = json.load(config_file)
@@ -63,6 +78,9 @@ def raw_to_dsp(f_raw, f_dsp, dsp_config, lh5_tables=None, database=None,
         raise Exception('Error, dsp_config must be an dict')
 
     raw_store = lh5.LH5Store()
+=======
+    raw_store = lh5.Store()
+>>>>>>> main:pygama/io/raw_to_dsp.py
     lh5_file = raw_store.gimme_file(f_raw, 'r')
     if lh5_file is None:
         print(f'raw_to_dsp: input file not found: {f_raw}')
@@ -90,6 +108,11 @@ def raw_to_dsp(f_raw, f_dsp, dsp_config, lh5_tables=None, database=None,
         print("Empty lh5_tables, exiting...")
         sys.exit(1)
 
+    # load DSP config (default: one config file for all tables)
+    if isinstance(dsp_config, str):
+        with open(dsp_config, 'r') as config_file:
+            dsp_config = json.load(config_file, object_pairs_hook=OrderedDict)
+
     # get the database parameters. For now, this will just be a dict in a json
     # file, but eventually we will want to interface with the metadata repo
     if isinstance(database, str):
@@ -107,11 +130,34 @@ def raw_to_dsp(f_raw, f_dsp, dsp_config, lh5_tables=None, database=None,
                 print('Overwriting existing file:', f_dsp)
             os.remove(f_dsp)
 
+    # write processing metadata
+    dsp_info = lh5.Struct()
+    dsp_info.add_field('timestamp', lh5.Scalar(np.uint64(time.time())))
+    dsp_info.add_field('python_version', lh5.Scalar(sys.version))
+    dsp_info.add_field('numpy_version', lh5.Scalar(np.version.version))
+    dsp_info.add_field('h5py_version', lh5.Scalar(h5py.version.version))
+    dsp_info.add_field('hdf5_version', lh5.Scalar(h5py.version.hdf5_version))
+    dsp_info.add_field('pygama_version', lh5.Scalar(pygama_version))
+    dsp_info.add_field('pygama_branch', lh5.Scalar(git.branch))
+    dsp_info.add_field('pygama_revision', lh5.Scalar(git.revision))
+    dsp_info.add_field('pygama_date', lh5.Scalar(git.commit_date))
+
+    # loop over tables to run DSP on
     for tb in lh5_tables:
         # load primary table and build processing chain and output table
         tot_n_rows = raw_store.read_n_rows(tb, f_raw)
         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, 'r') as config_file:
+                dsp_config = json.load(config_file, object_pairs_hook=OrderedDict)
+            print('Processing table:', tb, 'with DSP config file:\n  ', f_config)
+
+        if not isinstance(dsp_config, dict):
+            raise Exception('Error, dsp_config must be an dict')
+
         chan_name = tb.split('/')[0]
         db_dict = database.get(chan_name) if database else None
         tb_name = tb.replace('/raw', '/dsp')
@@ -122,6 +168,7 @@ def raw_to_dsp(f_raw, f_dsp, dsp_config, lh5_tables=None, database=None,
             write_offset = raw_store.read_n_rows(tb_name, f_dsp)
 
         print(f'Processing table: {tb} ...')
+<<<<<<< HEAD:pygama/dsp/build_dsp.py
         lh5_it = lh5.LH5Iterator(f_raw, tb, buffer_len = buffer_len)
         proc_chain = None
         tb_out = None
@@ -131,6 +178,11 @@ def raw_to_dsp(f_raw, f_dsp, dsp_config, lh5_tables=None, database=None,
             
             if verbose > 0:
                 update_progress(start_row/tot_n_rows)
+=======
+
+        for start_row in tqdm_range(0, int(tot_n_rows), buffer_len, verbose):
+            lh5_in, n_rows = raw_store.read_object(tb, f_raw, start_row=start_row, n_rows=buffer_len, field_mask = mask, obj_buf=lh5_in)
+>>>>>>> main:pygama/io/raw_to_dsp.py
             n_rows = min(tot_n_rows-start_row, n_rows)
             try:
                 proc_chain.execute(0, n_rows)
@@ -138,6 +190,7 @@ def raw_to_dsp(f_raw, f_dsp, dsp_config, lh5_tables=None, database=None,
                 # Update the wf_range to reflect the file position
                 e.wf_range = "{}-{}".format(e.wf_range[0]+start_row, e.wf_range[1]+start_row)
                 raise e
+<<<<<<< HEAD:pygama/dsp/build_dsp.py
             
             raw_store.write_object(obj=tb_out,
                                    name=tb_name,
@@ -166,6 +219,20 @@ def raw_to_dsp(f_raw, f_dsp, dsp_config, lh5_tables=None, database=None,
     dsp_info.add_field('pygama_revision', lh5.Scalar(pygama.git.revision))
     dsp_info.add_field('pygama_date', lh5.Scalar(pygama.git.commit_date))
     dsp_info.add_field('dsp_config', lh5.Scalar(json.dumps(dsp_config, indent=2)))
+=======
+
+            raw_store.write_object(tb_out, tb.replace('/raw', '/dsp'), f_dsp, n_rows=n_rows)
+
+        if chan_config is not None:
+            info_dsp = f'dsp_config/{tb}'
+        else:
+            info_dsp = 'dsp_config'
+        dsp_info.add_field(info_dsp, lh5.Scalar(json.dumps(dsp_config, indent=2)))
+
+        print(f'Done.  Writing to file: {f_dsp}')
+
+    # write metadata to file
+>>>>>>> main:pygama/io/raw_to_dsp.py
     raw_store.write_object(dsp_info, 'dsp_info', f_dsp)
 
     t_elap = (time.time() - t_start) / 60
diff --git a/pygama/dsp/build_processing_chain.py b/pygama/dsp/build_processing_chain.py
new file mode 100644
index 000000000..5cd1a3130
--- /dev/null
+++ b/pygama/dsp/build_processing_chain.py
@@ -0,0 +1,300 @@
+import json
+import re
+import importlib
+from copy import deepcopy
+
+from pygama.dsp.ProcessingChain import ProcessingChain
+from pygama.dsp.units import *
+from pygama import lh5
+
+
+def build_processing_chain(lh5_in, dsp_config, db_dict = None,
+                           outputs = None, verbosity=1, block_width=16):
+    """
+    Produces a ProcessingChain object and an lh5 table for output parameters
+    from an input lh5 table and a json recipe.
+    
+    Parameters
+    ----------
+    lh5_in : lgdo.Table
+        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
+        parameter from raw parameters. The format is as follows:
+        {
+            "outputs" : [ "parnames", ... ] -> list of output parameters
+                 to compute by default; see outputs parameter.
+            "processors" : {
+                 "name1, ..." : { -> names of parameters computed
+                      "function" : str -> 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
+                      "module" : str -> name of module containing function
+                      "args" : [ str or numeric, ... ] -> list of names of
+                           computed and input parameters or constant values
+                           used as inputs to function. Note that outputs
+                           should be fed by reference as args! Arguments read
+                           from the database are prepended with db.
+                      "kwargs" : dict -> keyword arguments for
+                           ProcesssingChain.add_processor.
+                      "init_args" : [ str or numeric, ... ] -> list of names
+                           of computed and input parameters or constant values
+                           used to initialize a gufunc via a factory function
+                      "unit" : str or [ strs, ... ] -> units for parameters
+                      "defaults" : dict -> default value to be used for
+                           arguments read from the database
+                      "prereqs" : DEPRECATED [ strs, ...] -> list of parameters
+                           that must be computed before these can
+                 }
+    outputs: [str, ...] (optional)
+        List of parameters to put in the output lh5 table. If None,
+        use the parameters in the 'outputs' list from config
+    db_dict: dict (optional)
+        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.
+    verbosity : int (optional)
+        0: Print nothing (except errors...)
+        1: Print basic warnings (default)
+        2: Print basic debug info
+        3: Print friggin' everything!    
+    block_width : int (optional)
+        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 : ProcessingChain object that is executed
+        field_mask : List of input fields that are used
+        lh5_out : output lh5 table containing processed values
+    """
+    proc_chain = ProcessingChain(block_width, lh5_in.size, verbosity = verbosity)
+    
+    if isinstance(dsp_config, str):
+        with open(dsp_config) as f:
+            dsp_config = json.load(f)
+    elif dsp_config is None:
+        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']
+
+    processors = dsp_config['processors']
+    
+    # prepare the processor list
+    multi_out_procs = {}
+    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!='']
+        if len(keys)>1:
+            for k in keys:
+                multi_out_procs[k] = key
+
+        # parse the arguments list for prereqs, if not included explicitly
+        if not 'prereqs' in node:
+            prereqs = []
+            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 and prereq != 'db':
+                        prereqs.append(prereq)
+            node['prereqs'] = prereqs
+
+        if verbosity>=2:
+            print("Prereqs for", key, "are", node['prereqs'])
+
+    processors.update(multi_out_procs)
+    
+    # Recursive function to crawl through the parameters/processors and get
+    # a sequence of unique parameters such that parameters always appear after
+    # their dependencies. For parameters that are not produced by the ProcChain
+    # (i.e. input/db parameters), add them to the list of leafs
+    # https://www.electricmonk.nl/docs/dependency_resolving_algorithm/dependency_resolving_algorithm.html
+    def resolve_dependencies(par, resolved, leafs, unresolved=[]):
+        if par in resolved:
+            return
+        elif par in unresolved:
+            raise ProcessingChainError('Circular references detected: %s -> %s' % (par, edge))
+
+        # if we don't find a node, this is a leaf
+        node = processors.get(par)
+        if node is None:
+            if par not in leafs:
+                leafs.append(par)
+            return
+
+        # if it's a string, that means it is part of a processor that returns multiple outputs (see above); in that case, node is a str pointing to the actual node we want
+        if isinstance(node, str):
+            resolve_dependencies(node, resolved, leafs, unresolved)
+            return
+        
+        edges = node['prereqs']
+        unresolved.append(par)
+        for edge in edges:
+            resolve_dependencies(edge, resolved, leafs, unresolved)
+        resolved.append(par)
+        unresolved.remove(par)
+
+    proc_par_list = [] # calculated from processors
+    input_par_list = [] # input from file and used for processors
+    copy_par_list = [] # copied from input to output
+    out_par_list = []
+    for out_par in outputs:
+        if out_par not in processors:
+            copy_par_list.append(out_par)
+        else:
+            resolve_dependencies(out_par, proc_par_list, input_par_list)
+            out_par_list.append(out_par)
+
+    if verbosity>0:
+        print('Processing parameters:', str(proc_par_list))
+        print('Required input parameters:', str(input_par_list))
+        print('Copied output parameters:', str(copy_par_list))
+        print('Processed output parameters:', str(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:
+            print("I don't know what to do with " + input_par + ". Building output without it!")
+        elif isinstance(buf_in, lh5.Array):
+            proc_chain.add_input_buffer(input_par, buf_in.nda)
+        elif isinstance(buf_in, lh5.Table):
+            # check if this is waveform
+            if 't0' and 'dt' and 'values' in buf_in:
+                proc_chain.add_input_buffer(input_par, buf_in['values'].nda, 'float32')
+                clk = buf_in['dt'].nda[0] * unit_parser.parse_unit(lh5_in['waveform']['dt'].attrs['units'])
+                if proc_chain._clk is not None and proc_chain._clk != clk:
+                    print("Somehow you managed to set multiple clock frequencies...Using " + str(proc_chain._clk))
+                else:
+                    proc_chain._clk = clk
+
+    # 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']
+        for i, arg in enumerate(args):
+            if isinstance(arg, str) and 'db.' in arg:
+                res = [i.start() for i in re.finditer('db.', arg)]
+                for rs in res:
+                    first = args[i].find('db.')
+                    out = re.findall("[\dA-Za-z_.]*", args[i][first+3:])[0]
+                    lookup_path = out.split(".")
+                    database_str = f"db.{out}"
+                    try:
+                        node = db_dict
+                        for key in lookup_path:
+                            node = node[key]
+                        if not isinstance(node, str):
+                            node =str(node)
+                        args[i] = args[i].replace(database_str, node)
+                        if(verbosity>0):
+                            print("Database lookup: found", node, "for", database_str)
+                    except:
+                        try:
+                            default_val = recipe['defaults'][database_str]
+                            if not isinstance(default_val, str):
+                                default_val =str(default_val)
+                            args[i] = args[i].replace(database_str, default_val)
+                            if(verbosity>0):
+                                print("Database lookup: using default value of", default_val, "for", database_str)
+                        except:
+                            raise Exception('Did not find', database_str, 'in database, and could not find default value.')            
+        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 = recipe['init_args']
+            for i, arg in enumerate(init_args):
+                if isinstance(arg, str) and 'db.' in arg:
+                    res = [i.start() for i in re.finditer('db.', arg)]
+                    for rs in res:
+                        first = init_args[i].find('db.')
+                        out = re.findall("[\dA-Za-z_.]*", init_args[i][first+3:])[0]
+                        lookup_path = out.split(".")
+                        database_str = f"db.{out}"
+                        try:
+                            node = db_dict
+                            for key in lookup_path:
+                                node = node[key]
+                            if not isinstance(node, str):
+                                node =str(node)
+                            init_args[i] = init_args[i].replace(database_str, node)
+                            if(verbosity>0):
+                                print("Database lookup: found", node, "for", database_str)
+                        except:
+                            try:
+                                default_val = recipe['defaults'][database_str]
+                                if not isinstance(default_val, str):
+                                    default_val =str(default_val)
+                                init_args[i] = init_args[i].replace(database_str, default_val)
+                                if(verbosity>0):
+                                    print("Database lookup: using default value of", default_val, "for", database_str)
+                            except:
+                                raise Exception('Did not find', database_str, 'in database, and could not find default value.') 
+                    arg = init_args[i]
+
+                # see if string can be parsed by proc_chain
+                if isinstance(arg, str):
+                    init_args[i] = proc_chain.get_variable(arg)
+                    
+            if(verbosity>1):
+                print("Building function", func.__name__, "from init_args", init_args)
+            func = func(*init_args)
+        except KeyError:
+            pass
+        proc_chain.add_processor(func, *args, **kwargs)
+
+    
+    # build the output buffers
+    lh5_out = lh5.Table(size=proc_chain._buffer_len)
+    
+    # add inputs that are directly copied
+    for copy_par in copy_par_list:
+        buf_in = lh5_in.get(copy_par)
+        if isinstance(buf_in, lh5.Array):
+            lh5_out.add_field(copy_par, buf_in)
+        elif isinstance(buf_in, lh5.Table):
+            # check if this is waveform
+            if 't0' and 'dt' and 'values' in buf_in:
+                lh5_out.add_field(copy_par, buf_in['values'])
+                clk = buf_in['dt'].nda[0] * unit_parser.parse_unit(lh5_in['waveform']['dt'].attrs['units'])
+                if proc_chain._clk is not None and proc_chain._clk != clk:
+                    print("Somehow you managed to set multiple clock frequencies...Using " + str(proc_chain._clk))
+                else:
+                    proc_chain._clk = clk
+        else:
+            print("I don't know what to do with " + input_par + ". Building output without it!")
+    
+    # finally, add the output buffers to lh5_out and the proc chain
+    for out_par in out_par_list:
+        recipe = processors[out_par]
+        # special case for proc with multiple outputs
+        if isinstance(recipe, str):
+            i = [k for k in re.split(",| ", recipe) if k!=''].index(out_par)
+            recipe = processors[recipe]
+            unit = recipe['unit'][i]
+        else:
+            unit = recipe['unit']
+        
+        try:
+            scale = convert(1, unit_parser.parse_unit(unit), clk)
+        except InvalidConversion:
+            scale = None
+        
+        buf_out = proc_chain.get_output_buffer(out_par, unit=scale)
+        lh5_out.add_field(out_par, lh5.Array(buf_out, attrs={"units":unit}) )
+
+    field_mask = input_par_list + copy_par_list
+    return (proc_chain, field_mask, lh5_out)
diff --git a/pygama/dsp/processors.py b/pygama/dsp/processors.py
index b4cf2477a..2a50f3fe4 100644
--- a/pygama/dsp/processors.py
+++ b/pygama/dsp/processors.py
@@ -5,10 +5,60 @@
 The ufunc interface provides additional information about the function
 signatures that enables broadcasting the arrays and SIMD processing. Thanks to
 the ufunc interface, they can also be called to return a numpy array, but if
- this is done, memory will be allocated for this array, slowing things down.
+this is done, memory will be allocated for this array, slowing things down.
+
+The pygama processors use the
+[ufunc](https://docs.scipy.org/doc/numpy/reference/ufuncs.html) framework,
+which is designed to encourage highly performant python practices. Ufuncs have
+several advantages:
+
+1. Ufuncs work with [numpy
+arrays](https://docs.scipy.org/doc/numpy/reference/arrays.html). Numpy arrays
+are arrays of same-typed objects that are stored adjacently in memory
+(equivalent to a dynamically allocated C-array or a C++ vector). Compared to
+standard python lists, they perform computations much faster. They are ideal
+for representing waveforms and transformed waveforms.
+
+2. Ufuncs perform vectorized operations. Vectorization causes commands to
+perform the same operation on all components of an array. For example, the
+ufunc `np.add(a, b, out=c)` (equivalently `c=a+b`) is equivalent to:
+```
+for i in range(len(c)):
+    c[i] = a[i] + b[i]
+```
+Loops are slow in python since it is an interpreted language; vectorized
+commands remove the loop and only call the python interpreter once.
+
+Furthermore, ufuncs are capable of
+[broadcasting](https://docs.scipy.org/doc/numpy/reference/ufuncs.html#broadcasting)
+their dimensions. This involves a safety check to ensure the dimensions of `a`
+and `b` are compatible sizes. It also will automatically replicate a size-1
+dimension over another one, enabling the addition of a scalar to a vector
+quantity. This is useful, as it allows us to process multiple waveforms at
+once.
+
+One of the biggest advantages of vectorized ufuncs is that many of them will
+take advantage of SIMD (same input-multiple data) 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 ufuncs will automatically use these for us, speeding
+up our code!
+
+3. Ufuncs are capable of calculating their output in place, meaning they can
+place calculated values in pre-allocated memory rather than allocating and
+returning new values. This is important because memory allocation is one of the
+slowest processes computers can perform, and should be avoided. With ufuncs,
+this can be done using the out keyword in arguments (ex `np.add(a, b, out=c)`,
+or more succinctly, `np.add(a, b, c)`). While this may seem counterintuitive at
+first, the alternative (`c = np.add(a,b)` or `c = a+b`) causes an entirely new
+array to be allocated, with c pointing at that. These array allocations can add
+up very quickly: `e = a*b + c*d`, for example, would allocate 3 different
+arrays: one for `a*b`, one for `c*d`, and one for the sum of those two. As we
+write ufuncs to use for LEGEND, it is important that we try to use functions
+that operate in place as much as possible!
 """
 
-# I think there's a way to do this recursively, but I'll figure it out later...
 from ._processors.pole_zero import pole_zero, double_pole_zero
 from ._processors.fixed_time_pickoff import fixed_time_pickoff
 from ._processors.time_point_thresh import time_point_thresh
@@ -23,3 +73,10 @@
 from ._processors.convolutions import cusp_filter, zac_filter, t0_filter
 from ._processors.trap_filters import trap_filter, trap_norm, asym_trap_filter, trap_pickoff
 from ._processors.moving_windows import moving_window_left, moving_window_right, moving_window_multi, avg_current
+from ._processors.soft_pileup_corr import soft_pileup_corr, soft_pileup_corr_bl
+from ._processors.optimize import optimize_1pz, optimize_2pz
+from ._processors.saturation import saturation
+from ._processors.gaussian_filter1d import gaussian_filter1d
+from ._processors.get_multi_local_extrema import get_multi_local_extrema
+from ._processors.multi_t_filter import multi_t_filter, remove_duplicates
+from ._processors.multi_a_filter import multi_a_filter
diff --git a/pygama/io/hit_to_evt.py b/pygama/io/hit_to_evt.py
new file mode 100755
index 000000000..01d3518e5
--- /dev/null
+++ b/pygama/io/hit_to_evt.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+import os
+import time
+import argparse
+import h5py
+import numpy as np
+import pandas as pd
+from pprint import pprint
+
+import pygama.lh5 as lh5
+
+def hit_to_evt(f_hit:str, f_evt:str=None, lh5_tables:list=None, copy_cols:list=None,
+               overwrite:bool=True, evt_tb_name:str=None, builder_config:dict=None):
+    """
+    Given an input file containing separate Tables for each active data taker,
+    create an output Table which merges all channels together, sorting everything
+    by ascending timestamp, and grouping coincident hits in each channel together
+    as "events".  The output table will contain columns ['ix_evt', 'ix_sub']
+    denoting the event and sub-event (hit), in addition to any other columns
+    we desire to copy over into the output file.
+
+    Parameters
+    ----------
+    f_hit : str
+        Input file containing multiple LH5 tables, one for each channel.
+        Usually a RAW or DSP file.
+    f_evt : str
+        Output file name.
+    lh5_tables : list (optional)
+        Specify the list of tables to analyze, instead of detecting them
+        automatically by default.
+    copy_cols : list (optional)
+        Specify the list of columns to copy into the output file.  For a DSP
+        file, it should be all columns by default, while for a RAW file, we
+        probably do NOT want to copy the waveforms into the output file.
+    evt_tb_name : str (optional)
+        Specify the name of the output table instead of automatically setting it.
+    builder_config : dict (optional)
+        Give a dict of kwargs for `cluster_events`, including coincidence window
+        times, conversion to seconds, and names of timestamp and channel columns.
+
+    Returns
+    -------
+    tcm : pd.Dataframe (conditional!)
+        If f_evt is not set, don't write to LH5 file, and instead return a
+        DataFrame.
+    """
+    t_start = time.time()
+
+    # get a list of LH5 tables to decode in the input file
+    if lh5_tables is None:
+        lh5_tables, lh5_cols = [], {}
+
+        def find_tables(name, obj):
+            for attr, val in obj.attrs.items():
+                if 'table' in val and 'waveform' not in name: # ignore waveform tables
+                    lh5_tables.append(name)
+                    col_list = val.replace('table{', '').replace('}','').split(',')
+                    if 'waveform' in col_list: col_list.remove('waveform')
+                    lh5_cols[name] = col_list
+
+        with h5py.File(f_hit) as hf:
+            # to iterate through all groups, you have to pass 'visititems' a function
+            hf.visititems(find_tables)
+
+        # for now, assume all column names are the same!
+        if copy_cols is None:
+            tb_0 = list(lh5_cols.keys())[0]
+            copy_cols = lh5_cols[tb_0]
+
+    # get event builder options
+    if builder_config is None:
+        builder_config = {
+        'ts_unit' : 1e-8,       # give the conversion of timestamps to seconds
+        'ch_col' : 'channel',   # name of column with channel ID (should be int)
+        'ts_col' : 'timestamp', # name of column with timestamps
+        'coin_window' : 4e-6,   # length of coincidence window in seconds
+        'data_cols' : copy_cols # columns to copy over into output table
+        }
+
+    # show status before running
+    print('Tables to merge:\n', lh5_tables)
+    print('Event builder config:')
+    print(builder_config)
+
+    # read LH5 tables and convert to DataFrame.  This copy and conversion is
+    # a required step!  (Clint will be happy to discuss the reasons with you.)
+    print('Loading dataframes ...')
+    dfs = []
+    for tb in lh5_tables:
+        df = lh5.load_dfs(f_hit, copy_cols, lh5_group=tb, idx_list=None, verbose=False)
+        print(f'Table: {tb}, rows:{len(df)}')
+        dfs.append(df)
+        # print(df)
+
+    # # sanity check - test cluster_events with an extra fake channel, #7, with slightly changed timestamps
+    # from scipy.ndimage.filters import gaussian_filter
+    # df_extra = dfs[1].copy()
+    # df_extra['timestamp'] = gaussian_filter(df_extra['timestamp'], sigma=0.1)
+    # df_extra['channel'] = 7
+    # dfs.append(df_extra)
+
+    # run cluster_events
+    print('Building time coincidence map ...')
+    tcm = cluster_events(dfs, **builder_config)
+    print('Done.  TCM output columns:')
+    print(tcm.columns)
+
+    # examine tcm
+    # tcm_view = ['channel','tcm_sec','tcm_dt','ix_evt','ix_hit']
+    # pd.options.display.max_rows = 50
+    # pd.options.display.float_format = '{:,.3e}'.format
+    # print(tcm[tcm_view].head(50))
+    # print(tcm)
+    # print(tcm.columns)
+
+    # write to file if f_evt is set, or return in-memory DataFrame if it isn't.
+    if f_evt is None:
+        return tcm
+
+    print('Writing to file:', f_evt)
+    if os.path.exists(f_evt):
+        os.remove(f_evt)
+
+    sto = lh5.Store()
+    col_dict = {col : lh5.Array(tcm[col].values, attrs={'units':''}) for col in tcm.columns}
+    tb_tcm = lh5.Table(size=len(tcm), col_dict=col_dict)
+    tb_name = 'events'
+    sto.write_object(tb_tcm, tb_name, f_evt)
+
+    # # sanity check, read it back in and convert back to DataFrame
+    # df_check = lh5.load_dfs(f_evt, tcm.columns, lh5_group='events', idx_list=None, verbose=False)
+    # print(tcm)
+    # print(df_check)
+
+    t_elap = time.time() - t_start
+    print(f'Done!  Time elapsed: {t_elap:.2f} sec.')
+
+
+def cluster_events(tb_list:list, ts_unit:float=1e-8, ch_col:str='channel',
+                   ts_col:str='timestamp', coin_window:float=4e-6,
+                   data_cols:list=None):
+    """
+    Create a time coincidence map (TCM), given a list of data tables from separate channels.
+    Assume that all tables are from different channels in a SINGLE cycle file.
+    Hopefully we won't need to extend this to event building across multiple cycles
+    as in Majorana.
+    - Sort events in DataFrames by strictly ascending timestamps
+    - Group hits if the difference between times is less than `coin_window` (default length 4*us)
+    - Don't allow events from the same channel into any event.
+    - TODO: add a "t_offset" output column which is the time since the FIRST hit in the event.
+
+    Parameters
+    ----------
+    tb_list : list
+        A list of N tables containing 'channel' & 'timestamp' columns.
+    ts_unit : float (optional)
+        Conversion factor for timestamps in native units to SECONDS.
+    ch_col : str (optional)
+        Name of the common column to use as 'channel'.  Default is 'channel'.
+    ts_col : str (optional)
+        Name of the common column to use as 'timestamp'.  Default is 'timestamp'.
+    coin_window : float (optional)
+        The default clustering time in seconds. (4e-6 sec is good for HPGe detectors)
+        If events in other channels occur within the first window, extend the window
+        by this amount and search again.
+    data_cols : list (optional)
+        Copy over additional columns (such as DSP parameters) when we sort events together
+        by timestamps.  This is handy for event building DSP files with pandas, then
+        optionally converting back to LH5 tables for file i/o.
+
+    Returns
+    -------
+    tcm : DataFrame
+        Return a table with NEW columns: ['ix_evt','ix_hit','tcm_sec','tcm_dt','idx_row_{ch}'].
+        If data_cols is set, these columns will be added to the input tables,
+        usually dsp data.
+    """
+    if not isinstance(tb_list[0], pd.DataFrame):
+        print("LH5 tables not supported yet, but easy, just need to convert them to DataFrame")
+        return None
+
+    # create 'ts_sec' timestamps for each channel, using the supplied conversion to seconds.
+    for df in tb_list:
+        df['tcm_sec'] = df[ts_col] * ts_unit
+
+        # throw an error if we detect resetting timestamps
+        ts = df['tcm_sec'].values
+        tdiff = np.diff(ts)
+        tdiff = np.insert(tdiff, 0 , 0)
+        ix_resets = np.where(tdiff < 0)
+        if len(ix_resets[0]) > 0:
+            print('Warning! timestamps reset for this channel.  TC map will be total nonsense!  AAAAAHHH')
+
+        # save the original index for reverse lookup
+        chan = df[ch_col].unique()[0]
+        df[f'ix_row_{chan}'] = df.index.values
+
+    # # make a list of columns from the input tables we want to copy over
+    # copy_cols = ['tcm_sec'] + ch_rows
+    # if data_cols is not None:
+    #     copy_cols.extend(data_cols)
+    # copy_cols = sorted(list(set(copy_cols))) # drop duplicates
+
+    # create a new dataframe where we SORT ALL ROWS by a striclty ascending timestamp
+    dfs = tb_list
+    tcm = pd.concat(dfs).sort_values('tcm_sec')
+    tcm.reset_index(inplace=True, drop=True)
+
+    # create the event column by comparing the time since last event to the coincindence window
+    tcm['tcm_dt'] = tcm['tcm_sec'].diff()
+    tcm['ix_evt'] = (tcm.tcm_dt > coin_window).cumsum()
+
+    # create the sub-event column (groupbys are easy with pandas, hard with LH5 tables.)
+    tcm['ix_hit'] = tcm.groupby(tcm.ix_evt).cumcount()
+
+    return tcm
+
+
+if __name__=='__main__':
+    doc = """Demonstrate usage of the `hit_to_evt` function, to build a time
+    coincidence map and organize data from many channels in a single cycle file,
+    into an event-like structure where coincindent events are time-ordered and
+    grouped together into sub-events."""
+
+    # parse user args
+    rthf = argparse.RawTextHelpFormatter
+    par = argparse.ArgumentParser(description=doc, formatter_class=rthf)
+    arg, st, sf = par.add_argument, 'store_true', 'store_false'
+    arg('input', type=str, help='input file name (required)')
+    arg('-o', '--output', type=str, help='output file name')
+    args = par.parse_args()
+
+    # set i/o
+    f_in = args.input
+    f_out = None if not args.output else args.output
+
+    # run hit_to_evt
+    hit_to_evt(f_in, f_out)
diff --git a/pygama/io/orca_flashcam.py b/pygama/io/orca_flashcam.py
new file mode 100644
index 000000000..fee9e1750
--- /dev/null
+++ b/pygama/io/orca_flashcam.py
@@ -0,0 +1,453 @@
+import numpy as np
+
+from .orcadaq import OrcaDecoder, get_ccc, get_readout_info, get_auxhw_info
+from .fcdaq import FlashCamEventDecoder
+
+class ORCAFlashCamListenerConfigDecoder(OrcaDecoder):
+    '''
+    Decoder for FlashCam listener config written by ORCA
+    '''
+    def __init__(self, *args, **kwargs):
+        
+        self.decoder_name    = 'ORFlashCamListenerConfigDecoder'
+        self.orca_class_name = 'ORFlashCamListenerModel'
+        
+        # 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', },
+            'listener_id':  { '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__(args, kwargs)
+
+
+    def get_decoded_values(self, channel=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, verbose=False):
+        
+        data = np.frombuffer(packet, dtype=np.int32)
+        tbl  = lh5_tables
+        ii   = tbl.loc
+
+        tbl['readout_id'].nda[ii]  = (data[0] & 0xffff0000) >> 16
+        tbl['listener_id'].nda[ii] =  data[0] & 0x0000ffff
+        
+        for i,k in enumerate(self.decoded_values):
+            if i < 2: continue
+            tbl[k].nda[ii] = data[i-1]
+            if k == 'gps': break
+
+        data = np.frombuffer(packet, dtype=np.uint32)
+        data = data[list(self.decoded_values.keys()).index('ch_boardid')-1:]
+        for i in range(len(data)):
+            tbl['ch_boardid'].nda[ii][i]  = (data[i] & 0xffff0000) >> 16
+            tbl['ch_inputnum'].nda[ii][i] =  data[i] & 0x0000ffff
+
+        tbl.push_row()
+
+        
+class ORCAFlashCamListenerStatusDecoder(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)
+
+
+    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, channel=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, verbose=False):
+        
+        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()
+        
+
+class ORCAFlashCamADCWaveformDecoder(OrcaDecoder):
+    """
+    Decoder for FlashCam ADC data written by ORCA
+    """
+    def __init__(self, *args, **kwargs):
+        
+        self.decoder_name    = 'ORFlashCamADCWaveformDecoder'
+        self.orca_class_name = 'ORFlashCamADCModel'
+
+        # header values from Orca, then the values defined in fcdaq
+        self.decoded_values_template = {
+            'crate':    { 'dtype': 'uint8',  },
+            'card':     { 'dtype': 'uint8',  },
+            'channel':  { 'dtype': 'uint8',  },
+            'fcio_id':  { 'dtype': 'uint16', }  }
+        fc = FlashCamEventDecoder()
+        self.decoded_values_template.update(fc.decoded_values)
+        
+        super().__init__(*args, **kwargs)
+        self.decoded_values = {}
+        self.skipped_channels = {}
+
+    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('ORFlashCamADCModel: error - decoded_values not built')
+                return None
+            return list(dec_vals_list)[0][1] # return first thing found
+        if channel in self.decoded_values: return self.decoded_values[channel]
+        print('ORFlashCamADCModel: error - '
+              'no decoded values for channel ', channel)
+        return None
+
+
+    def max_n_rows_per_packet(self):
+        return 1
+
+
+    def set_object_info(self, object_info):
+        self.object_info = object_info
+        
+        # get the readout list for looking up the waveform length.
+        # catch AttributeError for when the header_dict is not yet set.
+        roi = []
+        try: roi=get_readout_info(self.header_dict, 'ORFlashCamListenerModel')
+        except AttributeError: pass
+        
+        for card_dict in self.object_info:
+            crate   = card_dict['Crate']
+            card    = card_dict['Card']
+            enabled = card_dict['Enabled']
+            # find the listener id for this card from the readout list
+            listener = -1
+            for ro in roi:
+                try:
+                    for obj in ro['children']:
+                        if obj['crate']   == crate and obj['station'] == card:
+                            listener = ro['uniqueID']
+                            break
+                except KeyError: pass
+            # with the listener id, find the event samples for that listener
+            samples = 0
+            if listener >= 0:
+                aux = get_auxhw_info(self.header_dict,
+                                     'ORFlashCamListenerModel', listener)
+                for info in aux:
+                    try: samples = max(samples, info['eventSamples'])
+                    except KeyError: continue
+            # for each enabled channel, set the decoded values and wf length
+            for channel in range(len(enabled)):
+                if not enabled[channel]: continue
+                ccc = get_ccc(crate, card, channel)
+                self.decoded_values[ccc] = {}
+                self.decoded_values[ccc].update(self.decoded_values_template)
+                if samples > 0:
+                    self.decoded_values[ccc]['waveform']['length'] = samples
+
+                    
+    def decode_packet(self, packet, lh5_tables,
+                      packet_id, header_dict, verbose=False):
+
+        data = np.frombuffer(packet, dtype=np.uint32)
+
+        # unpack lengths and ids from the header words
+        orca_header_length = (data[0] & 0xf0000000) >> 28
+        fcio_header_length = (data[0] & 0x0fc00000) >> 22
+        wf_samples         = (data[0] & 0x003fffc0) >> 6
+        crate              = (data[1] & 0xf8000000) >> 27
+        card               = (data[1] & 0x07c00000) >> 22
+        channel            = (data[1] & 0x00003c00) >> 10
+        fcio_id            =  data[1] & 0x000003ff
+        ccc = get_ccc(crate, card, channel)
+
+        # get the table for this crate/card/channel
+        tbl = lh5_tables
+        if isinstance(list(tbl.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
+            tbl = lh5_tables[ccc]
+        ii = tbl.loc
+
+        # check that the waveform length is as expected
+        if wf_samples != tbl['waveform']['values'].nda.shape[1]:
+            print('ORCAFlashCamADCWaveformDecoder warning: '
+                  'waveform of length ', wf_samples,' with expected length ',
+                  self.decoded_values[ccc]['waveform']['length'])
+
+        # 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['channel'].nda[ii]   = channel
+        tbl['fcio_id'].nda[ii]   = fcio_id
+        tbl['numtraces'].nda[ii] = 1
+
+        # set the time offsets
+        offset = orca_header_length - 1
+        tbl['to_mu_sec'].nda[ii]      = np.int32(data[offset])
+        tbl['to_mu_usec'].nda[ii]     = np.int32(data[offset+1])
+        tbl['to_master_sec'].nda[ii]  = np.int32(data[offset+2])
+        tbl['to_dt_mu_usec'].nda[ii]  = np.int32(data[offset+3])
+        tbl['to_abs_mu_usec'].nda[ii] = np.int32(data[offset+4])
+        tbl['to_start_sec'].nda[ii]   = np.int32(data[offset+5])
+        tbl['to_start_usec'].nda[ii]  = np.int32(data[offset+6])
+        toff = np.float64(data[offset+2]) + np.float64(data[offset+3])*1e-6
+
+        # set the dead region values
+        offset += 7
+        tbl['dr_start_pps'].nda[ii]   = np.int32(data[offset])
+        tbl['dr_start_ticks'].nda[ii] = np.int32(data[offset+1])
+        tbl['dr_stop_pps'].nda[ii]    = np.int32(data[offset+2])
+        tbl['dr_stop_ticks'].nda[ii]  = np.int32(data[offset+3])
+        tbl['dr_maxticks'].nda[ii]    = np.int32(data[offset+4])
+
+        # set the event number and clock counters
+        offset += 5
+        tbl['ievt'].nda[ii]         = np.int32(data[offset])
+        tbl['ts_pps'].nda[ii]       = np.int32(data[offset+1])
+        tbl['ts_ticks'].nda[ii]     = np.int32(data[offset+2])
+        tbl['ts_maxticks'].nda[ii]  = np.int32(data[offset+3])
+
+        # set the runtime and timestamp
+        tstamp = np.float64(data[offset]+1);
+        tstamp += np.float64(data[offset+2]) / (np.int32(data[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 -1 + fcio_header_length
+        tbl['baseline'].nda[ii] =  data[offset-1] & 0x0000ffff
+        tbl['energy'].nda[ii]   = (data[offset-1] & 0xffff0000) >> 16
+        wf = np.frombuffer(packet, dtype=np.uint16)[offset*2:
+                                                    offset*2 + wf_samples]
+        tbl['waveform']['values'].nda[ii][:] = wf
+
+        tbl.push_row()
diff --git a/pygama/lgdo/lh5_store.py b/pygama/lgdo/lh5_store.py
index a8e4692b5..3396d1929 100644
--- a/pygama/lgdo/lh5_store.py
+++ b/pygama/lgdo/lh5_store.py
@@ -484,7 +484,7 @@ def read_object(self, name, lh5_file, start_row=0, n_rows=sys.maxsize, idx=None,
                 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)
@@ -713,7 +713,7 @@ def load_nda(f_list, par_list, lh5_group='', idx_list=None, verbose=True):
         A list of parameters to read from each file
     lh5_group : str (optional)
         Optional group path within which to find the specified parameters
-    idx_list : list of index arrays
+    idx_list : list of index arrays, or a list of such lists
         For fancy-indexed reads. Must be one idx array for each file in f_list
     verbose : bool
         Print info on loaded data
@@ -725,7 +725,14 @@ def load_nda(f_list, par_list, lh5_group='', idx_list=None, verbose=True):
         Each entry contains the data for the specified parameter concatenated
         over all files in f_list
     """
-    if isinstance(f_list, str): f_list = [f_list]
+    if isinstance(f_list, str): 
+        f_list = [f_list]
+        if idx_list is not None: 
+            idx_list = [idx_list]
+    if idx_list is not None and len(f_list) != len(idx_list):
+        print(f"load_nda: f_list len ({len(f_list)}) != idx_list len ({len(idx_list)})!")
+        return None
+
     # Expand wildcards
     f_list = [f for f_wc in f_list for f in sorted(glob.glob(os.path.expandvars(f_wc)))]
     if verbose:
@@ -733,9 +740,14 @@ def load_nda(f_list, par_list, lh5_group='', idx_list=None, verbose=True):
 
     sto = LH5Store()
     par_data = {par : [] for par in par_list}
-    for f in f_list:
+    for ii, f in enumerate(f_list):
+        f = sto.gimme_file(f, 'r')
         for par in par_list:
-            data, _ = sto.read_object(f'{lh5_group}/{par}', f)
+            if f'{lh5_group}/{par}' not in f:
+                print(f'{lh5_group}/{par} not in file {f_list[ii]}')
+                return None
+            if idx_list is None: data, _ = sto.read_object(f'{lh5_group}/{par}', f)
+            else: data, _ = sto.read_object(f'{lh5_group}/{par}', f, idx=idx_list[ii])
             if not data: continue
             par_data[par].append(data.nda)
     par_data = {par : np.concatenate(par_data[par]) for par in par_list}
@@ -759,7 +771,7 @@ def load_dfs(f_list, par_list, lh5_group='', idx_list=None, verbose=True):
         Contains columns for each parameter in par_list, and rows containing all
         data for the associated parameters concatenated over all files in f_list
     """
-    return pd.DataFrame( load_nda(f_list, par_list, lh5_group, verbose) )
+    return pd.DataFrame( load_nda(f_list, par_list, lh5_group=lh5_group, idx_list=idx_list, verbose=verbose) )
 
 
 class LH5Iterator:
diff --git a/pygama/math/histogram.py b/pygama/math/histogram.py
index 274bf1e3f..5b224bbef 100644
--- a/pygama/math/histogram.py
+++ b/pygama/math/histogram.py
@@ -75,6 +75,8 @@ def get_hist(data, bins=None, range=None, dx=None, wts=None):
         bins = int((range[1] - range[0]) / dx)
 
     # bins includes left edge of first bin and right edge of all other bins
+    # allow scalar weights
+    if wts is not None and np.shape(wts) == (): wts = np.full_like(data, wts)
     hist, bins = np.histogram(data, bins=bins, range=range, weights=wts)
 
     if wts is None: 
@@ -400,10 +402,18 @@ def get_fwfm(fraction, hist, bins, var=None, mx=None, dmx=0, bl=0, dbl=0, method
         return 0, 0
 
 
-def plot_hist(hist, bins, var=None, show_stats=False, stats_hloc=0.75, stats_vloc=0.85, **kwargs):
+def plot_hist(hist, bins, var=None, show_stats=False, stats_hloc=0.75, stats_vloc=0.85, fill=False, fillcolor='r', fillalpha=0.2, **kwargs):
     """
     plot a step histogram, with optional error bars
     """
+    if fill: 
+        # the concat calls get the steps to draw correctly at the range boundaries
+        # where="post" tells plt to draw the step y[i] between x[i] and x[i+1]
+        save_color = None
+        if 'color' in kwargs: save_color = kwargs.pop('color')
+        elif 'c' in kwargs: save_color = kwargs.pop('c')
+        plt.fill_between(np.concatenate(([bins[0]], bins)), np.concatenate(([0], hist, [0])), step="post", color=fillcolor, alpha=fillalpha, **kwargs)
+        if save_color is not None: kwargs['color'] = save_color
     if var is None:
         # the concat calls get the steps to draw correctly at the range boundaries
         # where="post" tells plt to draw the step y[i] between x[i] and x[i+1]
diff --git a/pygama/math/peak_fitting.py b/pygama/math/peak_fitting.py
index 2dbce5ba3..9991a8f07 100644
--- a/pygama/math/peak_fitting.py
+++ b/pygama/math/peak_fitting.py
@@ -2,19 +2,25 @@
 from scipy.optimize import minimize, curve_fit, minimize_scalar, brentq
 from scipy.special import erf, erfc, gammaln
 from scipy.stats import crystalball
+from scipy.integrate import simps
 import sys
+import numba as nb
+import math
+from iminuit import Minuit, cost, util
 
-import pygama.math.histogram as ph
+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 USE FIT_BINNED
+
     do a binned fit to a histogram (nonlinear least squares).
     can either do a poisson log-likelihood fit (jason's fave) or
     use curve_fit w/ an arbitrary function.
-
     - 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.
@@ -23,53 +29,155 @@ def fit_hist(func, hist, bins, var=None, guess=None,
                   bounds to make sure that func does not go negative over the
                   x-range of the histogram.
     - method, bounds : options to pass to 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.
+
+    Inputs
+    ------
+
+    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, error cov_matrix : tuple(array, array, matrix)
+    """
+        
+    
     if guess is None:
         print("auto-guessing not yet implemented, you must supply a guess.")
         return None, None
 
-    if poissonLL:
-        if var is not None and not np.array_equal(var, hist):
-            print("variances are not appropriate for a poisson-LL fit!")
-            return None, None
-
-        if method is None:
-            method = "L-BFGS-B"
-
-        result = minimize(neg_poisson_log_like, x0=guess,
-                          args=(func, hist, bins, integral),
-                          method=method, bounds=bounds)
+    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)
 
-        coeff, cov_matrix = result.x, result.hess_inv.todense()
+    elif cost_func == 'Least Squares':
 
-    else:
         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 to avoid div-by-0 error in curve_fit
+        # 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)
-        sigma = np.sqrt(var)[mask]
         hist = hist[mask]
-        xvals = ph.get_bin_centers(bins)[mask]
-        if bounds is None:
-            bounds = (-np.inf, np.inf)
+        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):
+    """
 
-        coeff, cov_matrix = curve_fit(func, xvals, hist,
-                                      p0=guess, sigma=sigma, bounds=bounds)
+    Do a unbinned fit to data. 
+    Default is Extended Log Likelihood fit, with option for other cost functions.
 
-    return coeff, cov_matrix
+    Inputs
+    ------
+
+    func : the function to fit
+
+    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
+
+    Returns
+    ------
+    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
@@ -83,7 +191,6 @@ def goodness_of_fit(hist, bins, var, func, pars, method='var'):
         '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
@@ -102,75 +209,33 @@ def goodness_of_fit(hist, bins, var, func, pars, method='var'):
     if method == 'Neyman' and np.any(hist==0):
         print("goodness_of_fit: hist cannot contain zeros for Neyman method")
         return 0, 0
-
-    # compute chi2 numerator and denominator
-    yy = func(ph.get_bin_centers(bins), *pars)
-    numerator = (hist - yy)**2
-    if method == 'var':
-        denominator = var
-    elif method == 'Pearson':
-        denominator = yy
-    elif method == 'Neyman':
-        denominator = hist
+    
+    
+    # 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:
-        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 neg_log_like(params, f_likelihood, data, **kwargs):
-    """
-    given a likelihood function and data, return the negative log likelihood.
-    """
-    return -np.sum(np.log(f_likelihood(data, *params, **kwargs)))
-
-
-def fit_unbinned(f_likelihood, data, start_guess, min_method=None, bounds=None):
-    """
-    unbinned max likelihood fit to data with given likelihood func
-    """
-    if method is None:
-        method="L-BFGS-B" # minimization method, see docs
-
-    result = minimize(
-        neg_log_like, # function to minimize
-        x0 = start_guess, # start value
-        args = (f_likelihood, data),
-        method = min_method,
-        bounds = bounds)
-
-    return result.x
-
-
-def fit_binned(f_likelihood, hist, bin_centers, start_guess, var=None, bounds=None):
-    """
-    regular old binned fit (nonlinear least squares). data should already be
-    histogrammed (see e.g. pygama.analysis.histograms.get_hist)
-    # jason says this is deprecated. Use ph.fit_hist() instead.
-    """
-    sigma = None
-    if bounds is None:
-        bounds = (-np.inf, np.inf)
-
-    # skip "okay" bins with content 0 +/- 0 to avoid div-by-0 error in curve_fit
-    # if bin content is non-zero but var = 0 let the user see the warning
-    if var is not None:
-        zeros = (hist == 0)
-        zero_errors = (var == 0)
-        mask = ~(zeros & zero_errors)
-        sigma = np.sqrt(var)[mask]
-        hist = hist[mask]
-        bin_centers = bin_centers[mask]
-
-    # run curve_fit
-    coeff, var_matrix = curve_fit(f_likelihood, bin_centers, hist,
-                                  p0=start_guess, sigma=sigma, bounds=bounds)
-    return coeff
+        # 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):
     """
@@ -180,25 +245,11 @@ def get_bin_estimates(pars, func, hist, bins, integral=None, **kwargs):
     default.
     """
     if integral is None:
-        return func(ph.get_bin_centers(bins), *pars, **kwargs) * ph.get_bin_widths(bins)
+        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 neg_poisson_log_like(pars, func, hist, bins, integral=None, **kwargs):
-    """
-    Wrapper to give me poisson neg log likelihoods of a histogram
-        ln[ f(x)^n / n! exp(-f(x) ] = const + n ln(f(x)) - f(x)
-    """
-    mu = get_bin_estimates(pars, func, hist, bins, integral, **kwargs)
-
-    # func and/or integral should never give a negative value: let negative
-    # values cause errors that get passed to the user. However, mu=0 is okay,
-    # but causes problems for np.log(). When mu is zero there had better not be
-    # any counts in the bins. So use this to pull the fit like crazy.
-    return np.sum(mu - hist*np.log(mu+1.e-99))
-
-
 def poisson_gof(pars, func, hist, bins, integral=None, **kwargs):
     """
     The Poisson likelihood does not give a good GOF until the counts are very
@@ -211,24 +262,20 @@ def poisson_gof(pars, func, hist, bins, integral=None, **kwargs):
 
 
 def gauss_mode_width_max(hist, bins, var=None, mode_guess=None, n_bins=5, 
-                         poissonLL=False, inflate_errors=False, gof_method='var'):
+                         cost_func='Least Squares', inflate_errors=False, gof_method='var'):
     """
     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:
-
     A exp[ -(x-mu)^2 / 2 sigma^2 ] ~= 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 guassian also better approximates most peaks farther down
     the peak. However, the gauss fit is nonlinear and thus less stable.
-
     Parameters
     ----------
     hist : array-like
@@ -252,7 +299,6 @@ def gauss_mode_width_max(hist, bins, var=None, mode_guess=None, n_bins=5,
         if it is greater than 1
     gof_method : str (optional)
         method flag for goodness_of_fit
-
     Returns
     -------
     (pars, cov) : tuple (array, matrix)
@@ -269,8 +315,8 @@ def gauss_mode_width_max(hist, bins, var=None, mode_guess=None, n_bins=5,
             The covariance matrix for the 3 parameters in pars
     """
 
-    bin_centers = ph.get_bin_centers(bins)
-    if mode_guess is not None: i_0 = ph.find_bin(mode_guess, bins)
+    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]
@@ -281,8 +327,8 @@ def gauss_mode_width_max(hist, bins, var=None, mode_guess=None, n_bins=5,
     vv = None if var is None else var[i_0:i_n]
     guess = (mode_guess, width_guess, amp_guess)
     try:
-        pars, cov = fit_hist(gauss_basic, hist[i_0:i_n], bins[i_0:i_n+1], vv,
-                         guess=guess, poissonLL=poissonLL)
+        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)
     except:
         return None, None
     if pars[1] < 0: pars[1] = -pars[1]
@@ -294,11 +340,9 @@ def gauss_mode_width_max(hist, bins, var=None, mode_guess=None, n_bins=5,
 
 def gauss_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=False, inflate_errors=False, gof_method='var'):
     """ Alias for gauss_mode_width_max that just returns the max and mode
-
     Parameters
     --------
     See gauss_mode_width_max
-
     Returns
     -------
     (pars, cov) : tuple (array, matrix)
@@ -307,7 +351,6 @@ def gauss_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=Fa
             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
@@ -324,10 +367,8 @@ def gauss_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=Fa
 
 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
@@ -344,7 +385,6 @@ def taylor_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=F
     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
-
     Returns
     -------
     (pars, cov) : tuple (array, matrix)
@@ -353,7 +393,6 @@ def taylor_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=F
             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
@@ -363,13 +402,13 @@ def taylor_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=F
     >>> pgf.taylor_mode_max(hist, bins, var, n_bins=5)
     """
 
-    if mode_guess is not None: i_0 = ph.find_bin(mode_guess, bins)
+    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(ph.get_bin_centers(bins)[i_0:i_n], hist[i_0:i_n], 2, w=wts, cov='unscaled')
+    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
@@ -379,92 +418,386 @@ def taylor_mode_max(hist, bins, var=None, mode_guess=None, n_bins=5, poissonLL=F
     cov = np.matmul(jac, cov_jact)
     return (mode, maximum), cov
 
+@nb.njit(**kwd)
+def nb_erf(x):
 
-def gauss_basic(x, mu, sigma, height=1, C=0):
     """
-    define a gaussian distribution, w/ args: mu, sigma, height
-    (behaves differently than gauss() in fits)
+    Numba version of error function 
     """
-    return height * np.exp(-(x - mu)**2 / (2. * sigma**2)) + C
 
+    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):
 
-def gauss(x, mu, sigma, A=1, const=0):
     """
-    define a gaussian distribution, w/ args: mu, sigma, area, const.
+    Numba version of complementary error function 
     """
-    height = A / sigma / np.sqrt(2 * np.pi)
-    return gauss_basic(x, mu, sigma, height, const)
 
+    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):
 
-def gauss_int(x, mu, sigma, A=1):
     """
-    integral of a gaussian from 0 to x, w/ args: mu, sigma, area, const.
+    Gaussian, unnormalised for use in building pdfs, w/ args: mu, sigma.
     """
-    return A/2 * (1 + erf((x - mu)/sigma/np.sqrt(2)))
 
+    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):
 
-def gauss_lin(x, mu, sigma, a, b, m):
     """
-    gaussian + linear background function
+    Normalised Gaussian, w/ args: mu, sigma.
     """
-    return m * x + b + gauss(x, mu, sigma, a)
 
+    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
 
-def gauss_bkg(x, a, mu, sigma, bkg): # deprecate this?
+@nb.njit(**kwd)
+def gauss_cdf(x, mu, sigma):
     """
-    gaussian + const background function
+    gaussian cdf, w/ args: mu, sigma.
     """
-    return gauss(x, mu, sigma, a, bkg)
+    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)
 
-def radford_peak(x, mu, sigma, hstep, htail, tau, bg0, a=1, components=False):
+@nb.njit(**kwd)
+def gauss_pdf(x, mu, sigma, n_sig):
+    
     """
-    David Radford's HPGe peak shape function
+    Basic Gaussian pdf args; mu, sigma, n_sig (number of signal events)
     """
-    # make sure the fractional amplitude parameters stay reasonable
-    if htail < 0 or htail > 1:
-        return np.zeros_like(x)
-    if hstep < -1 or hstep > 1:
-        return np.zeros_like(x)
 
-    # compute the step, check the background isn't negative
-    step_f = step(x, mu, sigma, 0, a*hstep)
-    bg_term = bg0 # x*bg1... (maybe bg should be list of coefficients?)
-    if np.any(bg_term + step_f < 0):
-        return np.zeros_like(x)
+    return n_sig * gauss_norm(x,mu,sigma)
+
 
-    # compute the low energy tail
-    le_tail = gauss_tail(x, mu, sigma, a * htail, tau)
+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 backgorund
+    """
 
-    if not components:
-        # add up all the peak shape components
-        return (1 - htail) * gauss(x, mu, sigma, a) + bg_term + step_f + le_tail
+    if components==False:
+        return 1/(np.nanmax(x)-np.nanmin(x)) * n_bkg + n_sig * gauss_norm(x,mu,sigma)
     else:
-        # return individually to make a pretty plot
-        return (1 - htail), gauss(x, mu, sigma, a), bg_term, step_f, le_tail
+        return n_sig * gauss_norm(x,mu,sigma), 1/(np.nanmax(x)-np.nanmin(x)) * n_bkg
 
 
-def radford_peak_wrapped(x, A, mu, sigma, bkg, S, T, tau, components=False):
+def gauss_linear(x, n_sig, mu, sigma, n_bkg, b, m, components=False):
+    
     """
-    A wrapped version of David Radford's HPGe peak shape function, such that
-    the argument order and definition follows gauss_step
+    gaussian signal + linear background function
+    args: n_sig mu, sigma for the signal and n_bkg,b,m for the backgorund
     """
+    
+
+    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)
 
-    a = A + T
-    htail = T / a
-    hstep = S / (2*a)
+@nb.njit(**kwd)
+def step_int(x,mu,sigma, hstep):
 
-    return radford_peak(x, mu, sigma, hstep, htail, tau, bkg, a, components=components)
+    """
+    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
 
-def radford_fwhm(sigma, htail, tau, cov = None):
+@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(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 backgorund
+    """
+
+    try:
+        bkg= step(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 backgorund
+    """
+
+    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 backgorund
+    """
+    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(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_pdf(x, mu, sigma, tau), 
+                      gauss_tail_approx_pdf(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(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(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 
+    """
+
+    peak = gauss_norm(x,mu,sigma)
+    try: 
+        tail = gauss_tail(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 
+    """
+
+    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(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. TODO: also get the uncertainty
+    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 -radford_peak(E, 0, sigma, 0, htail, tau, 0, 1)
+        return -gauss_with_tail_pdf(np.array([E]), 0, sigma, htail, tau)[0]
     
     res = minimize_scalar( neg_radford_peak_bgfree,
                            args=(sigma, htail, tau),
@@ -474,260 +807,245 @@ def neg_radford_peak_bgfree(E, sigma, htail, tau):
 
     # root find this to find the half-max energies
     def radford_peak_bgfree_halfmax(E, sigma, htail, tau, half_max):
-        return radford_peak(E, 0, sigma, 0, htail, tau, 0, 1) - half_max
+        return gauss_with_tail_pdf(np.array([E]), 0, sigma, htail, tau)[0] - half_max
     
-    lower_hm = brentq( radford_peak_bgfree_halfmax,
+    try:
+        lower_hm = brentq( radford_peak_bgfree_halfmax,
                        -(2.5*sigma/2 + htail*tau), Emax,
                        args = (sigma, htail, tau, half_max) )
-    upper_hm = brentq( radford_peak_bgfree_halfmax,
+    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 = [0, sigma, 0, htail, tau, 0, 1]
-    gradmax = radford_parameter_gradient(Emax, pars);
+    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);
+    grad1 = radford_parameter_gradient(lower_hm, pars,step_norm)
     grad1 -= gradmax
-    grad1 /= radford_peakshape_derivative(lower_hm, pars);
-    grad2 = radford_parameter_gradient(upper_hm, pars);
+    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);
-    grad2 -= grad1;
+    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):
-    mu, sigma, hstep, htail, tau, bg0, a = pars
+def radford_peakshape_derivative(E, pars, step_norm):
+    n_sig, mu, sigma, htail, tau, n_bkg, hstep = pars
 
     sigma = abs(sigma)
-    gaus = gauss(E, mu, sigma)
+    gaus = gauss_norm(E, mu, sigma)
     y = (E-mu)/sigma
-    ret = -(1-htail)*y/sigma*gaus
-    ret -= htail/tau*(-gauss_tail(E, mu, sigma, 1, tau)+gaus)
-
-    return a*(ret - hstep*gaus)
+    ret = -(1-htail)*(y/sigma)*gaus
+    ret -= htail/tau*(-gauss_tail(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):
-    mu, sigma, hstep, htail, tau, bg0, amp = pars #bk gradient zero?
+def radford_parameter_gradient(E, pars, step_norm):
+    n_sig, mu, sigma, htail, tau, n_bkg, hstep = pars 
 
-    gaus = gauss(E, mu, sigma)
-    tailL = gauss_tail(E, mu, sigma, 1, tau)
-    step_f = step(E, mu, sigma, 0, 1)
+    gaus = gauss_norm(np.array([E, E-1]), mu, sigma)[0] 
+    tailL = gauss_tail(np.array([E, E-1]), mu, sigma, tau)[0] 
+    if n_bkg ==0:
+        step_f = 0
+    else:
+        step_f = pgf.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_amp = htail*tailL + (1-htail)*gaus + hstep*step_f
-    g_hs = amp*step_f
-    g_ft = amp*(tailL-gaus)
+    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
+    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) * amp
+    g_tau = -htail/tau*( (1.+sigtauL*y+sigtauL*sigtauL)*tailL - sigtauL*sigtauL*gaus) * n_sig
 
-    g_mu = amp*(g_mu + hstep*gaus)
-    g_sigma = amp*(g_sigma + hstep*y*gaus)
+    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_mu, g_sigma, g_hs, g_ft, g_tau, 0, g_amp
+    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):
 
-def get_fwhm_func(func, pars, cov = None):
-
-    if func == gauss_step:
-        amp, mu, sigma, bkg, step = pars
-        if cov is None:
-            return sigma*2*np.sqrt(2*np.log(2))
+    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 sigma*2*np.sqrt(2*np.log(2)), np.sqrt(cov[2][2])*2*np.sqrt(2*np.log(2))
+            return mu
 
-    if func == radford_peak:
-        mu, sigma, hstep, htail, tau, bg0, a = pars
-        return radford_fwhm(sigma, htail, tau, cov)
+    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
 
-    if func == radford_peak_wrapped:
-        A, mu, sigma, bg0, S, T, tau = pars
-        a = A + T
-        htail = T / a
-        hstep = S / a
-        newpars = mu, sigma, hstep, htail, tau, bg0, a
-        return get_fwhm_func(radford_peak, newpars) #couldn't work out how to transform covariance matrix, use simple radford_peak for uncertainty
     else:
-        print(f'get_fwhm_func not implemented for {func.__name__}')
+        print(f'get_mu_func not implemented for {func.__name__}')
         return None
 
+def get_fwhm_func(func, pars, cov = None):
 
-def get_mu_func(func, pars, cov = None):
-
-    if func == gauss_step:
-        amp, mu, sigma, bkg, step = pars
+    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 mu
+            return sigma*2*np.sqrt(2*np.log(2))
         else:
-            return mu, np.sqrt(cov[2][2])
+            return sigma*2*np.sqrt(2*np.log(2)), np.sqrt(cov[2][2])*2*np.sqrt(2*np.log(2))
 
-    if func == radford_peak:
-        mu, sigma, hstep, htail, tau, bg0, a = pars
-        if cov is None:
-            return mu
-        else:
-            return mu, np.sqrt(cov[0][0])
+    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 func == radford_peak_wrapped:
-        A, mu, sigma, bg0, S, T, tau = pars
-        if cov is None:
-            return mu
-        else:
-            return mu, np.sqrt(cov[1][1])
+        return radford_fwhm(sigma, htail, tau, cov)
     else:
         print(f'get_fwhm_func not implemented for {func.__name__}')
         return None
-    
-
-def gauss_tail(x, mu, sigma, tail, tau):
-    """
-    A gaussian tail function template
-    Can be used as a component of other fit functions
-    """
-    if tail == 0: return 0
-    x = np.asarray(x)
-    scalar_input = False
-    if x.ndim == 0:
-        x = x[None] # makes x1d
-        scalar_input = True
-
-    tmp = (x-mu)/tau + sigma**2/2/tau**2
-    tail_f = np.where(tmp < limit, 
-                      gauss_tail_exact(x, mu, sigma, tail, tau), 
-                      gauss_tail_approx(x, mu, sigma, tail, tau))
-
-    if scalar_input:
-        return np.squeeze(tail_f)
-    return tail_f
-
-
-def gauss_tail_exact(x, mu, sigma, tail, tau):
-    tmp = (x-mu)/tau + sigma**2/2/tau**2
-    abstau = np.absolute(tau)
-    tmp = np.where(tmp < limit, tmp, limit)
-    tail_f = tail/(2*abstau) * np.exp(tmp) * erfc( (tau*(x-mu)/sigma + sigma)/(np.sqrt(2)*abstau))
-    return tail_f
-
-
-def gauss_tail_approx(x, mu, sigma, tail, tau):
-    den = 1/(sigma + tau*(x-mu)/sigma)
-    tail_f = tail * sigma * gauss(x, mu, sigma) * den * (1.-tau*tau*den*den)
-    return tail_f
-
 
-def step(x, mu, sigma, bkg, a):
-    """
-    A step function template
-    Can be used as a component of other fit functions
-    """
-    step_f = bkg + a * erfc((x-mu)/(np.sqrt(2)*sigma))
-    return step_f
-
-
-def gauss_step(x, a, mu, sigma, bkg, s, components=False):
-    """
-    gaussian + step function for Compton spectrum
-    """
-    peak_f = gauss(x,mu,sigma,a)
-    step_f = step(x,mu,sigma,bkg,s)
-
-    peak = peak_f + step_f
-
-    if components:
-      return peak_f, step_f
-    else:
-      return peak
-
-
-def gauss_cdf(x, a, mu, sigma, tail, tau, bkg, s, components=False):
-    """
-    I guess this should be similar to radford_peak (peak + tail + step)
-    This is how I used it in root peak fitting scripts
-    """
-    peak_f = gauss(x, mu, sigma, a)
-    tail_f = gauss_tail(x, mu, sigma, tail, tau)
-    step_f = step(x, mu, sigma, bkg, s)
-
-    peak = peak_f + tail_f + step_f
-
-    if components:
-      return peak, tail_f, step_f, peak_f
+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:
-      return peak
-
+        print(f'get_total_events_func not implemented for {func.__name__}')
+        return None
 
-def Am_double(x,a1,mu1,sigma1,a2,mu2,sigma2,a3,mu3,sigma3,b1,b2,s1,s2,
-              components=False) :
+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)
     """
-
-    step1 = step(x,mu1,sigma1,b1,s1)
-    step2 = step(x,mu2,sigma2,b2,s2)
-
-    gaus1 = gauss(x,mu1,sigma1,a1)
-    gaus2 = gauss(x,mu2,sigma2,a2)
-    gaus3 = gauss(x,mu3,sigma3,a3)
-
-    #tail1 = gauss_tail(x,mu1,sigma1,t1,tau1)
-    #tail2 = gauss_tail(x,mu2,sigma2,t2,tau2)
-    double_f = step1 + step2 + gaus1 + gaus2 + gaus3# + tail1 + tail2
-
-    if components:
-       return double_f, gaus1, gaus2, gaus3, step1, step2#, tail1, tail2
+    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:
-       return double_f
+        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(x,a1,mu1,sigma1,a2,mu2,sigma2,b1,s1,components=False) :
+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
      """
-
-    step1 = step(x,mu1,sigma1,b1,s1)
-    #step2 = step(x,mu2,sigma2,b2,s2)
-
-    gaus1 = gauss(x,mu1,sigma1,a1)
-    gaus2 = gauss(x,mu2,sigma2,a2)
-    #gaus3 = gauss(x,mu3,sigma3,a3)
-
-    #tail1 = gauss_tail(x,mu1,sigma1,t1,tau1)
-    #tail2 = gauss_tail(x,mu2,sigma2,t2,tau2)
-    double_f = step1 +  gaus1 + gaus2
-
-    if components:
-       return double_f, gaus1, gaus2, step1
+    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 double_f
+        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):
     """
diff --git a/pygama/math/utils.py b/pygama/math/utils.py
index 8dbae5462..9439ba8d4 100644
--- a/pygama/math/utils.py
+++ b/pygama/math/utils.py
@@ -2,6 +2,7 @@
 pygama convenience functions.
 """
 import sys
+import tqdm
 import numpy as np
 import matplotlib.pyplot as plt
 
@@ -14,7 +15,7 @@ def get_dataset_from_cmdline(args, run_db, cal_db):
         arg("-r", "--run", nargs=1, help="load a single run")
     """
     from pygama import DataSet
-    
+
     if args["ds"]:
         ds_lo = int(args["ds"][0])
         try:
@@ -22,42 +23,88 @@ def get_dataset_from_cmdline(args, run_db, cal_db):
         except:
             ds_hi = None
         ds = DataSet(ds_lo, ds_hi, md=run_db, cal=cal_db, v=args["verbose"])
-    
+
     if args["run"]:
-        ds = DataSet(run=int(args["run"][0]), md=run_db, cal=cal_db, 
+        ds = DataSet(run=int(args["run"][0]), md=run_db, cal=cal_db,
                      v=args["verbose"])
     return ds
 
 
-def update_progress(progress, run=None):
+def sh(cmd, sh=False):
     """
-    adapted from from https://stackoverflow.com/a/15860757
+    input a shell command as you would type it on the command line.
     """
-    barLength = 20  # length of the progress bar
-    status = ""
-    if isinstance(progress, int):
-        progress = float(progress)
-    if not isinstance(progress, float):
-        progress = 0
-        status = "error: progress var must be float\r\n"
-    if progress < 0:
-        progress = 0
-        status = "Halt...\r\n"
-    if progress >= 1:
-        progress = 1
-        status = "Done...\r\n"
-    block = int(round(barLength * progress))
-
-    if run is None:
-        text = "\rProgress : [{}] {:0.1f}% {}".format(
-            "#" * block + "-" * (barLength - block), progress * 100, status)
-    else:
-        text = "\rProgress : [{}] {:0.1f}% {} (Run {})".format(
-            "#" * block + "-" * (barLength - block), progress * 100, status,
-            run)
+    decoders = []
+    for sub in DataTaker.__subclasses__():
+        for subsub in sub.__subclasses__():
+            try:
+                decoder = subsub(object_info) # initialize the decoder
+                decoders.append(decoder)
+            except Exception as e:
+                print(e)
+                pass
+    return decoders
+
+
+def update_progress(tqdm_bar, progress):
+    tqdm_bar.update(progress)
+
+
+def tqdm_range(start, stop, step=1, verbose=False, text=None, bar_length=20, unit=None):
+    """
+    Uses tqdm.trange which wraps around the python range and also has the option
+    to display a progress
+
+    Example:
+
+        for start_row in range(0, tot_n_rows, buffer_len):
+            ...
+
+            Can be converted to the following
+
+        for start_row in tqdm_range(0, tot_n_rows, buffer_len, verbose):
+            ...
+
+    Parameters
+    ----------
+    start : int
+        starting iteration value
+    stop : int
+        ending iteration value
+    step : int
+        step size inbetween each iteration
+    verbose : int
+        verbose = 0 hides progress bar verbose > 0 displays progress bar
+    text : str
+        text to display in front of the progress bar
+    bar_length : str
+        horizontal length of the bar in cursor spaces
+
+    Returns
+    -------
+    iterable : tqdm.trange
+        object that can be iterated over in a for loop
+    """
+
+    hide_bar = True
+    if isinstance(verbose, int):
+        if verbose > 0:
+            hide_bar = False
+    elif isinstance(verbose, bool):
+        if verbose is True:
+            hide_bar = False
+
+    if text is None:
+        text = "Processing"
+
+    if unit is None:
+        unit = "it"
+
+    bar_format = f"{{l_bar}}{{bar:{bar_length}}}{{r_bar}}{{bar:{-bar_length}b}}"
 
-    sys.stdout.write(text)
-    sys.stdout.flush()
+    return tqdm.trange(start, stop, step,
+                       disable=hide_bar, desc=text,
+                       bar_format=bar_format, unit=unit, unit_scale=True)
 
 
 def sizeof_fmt(num, suffix='B'):
@@ -234,7 +281,7 @@ def linear_fit_by_sums(x, y, var=1):
     """
     y = y/var
     x = x/var
-    sum_wts = len(y)/var if np.isscalar(var) else np.sum(1/var) 
+    sum_wts = len(y)/var if np.isscalar(var) else np.sum(1/var)
     sum_x = np.sum(x)
     sum_xx = np.sum(x*x)
     sum_y = np.sum(y)
diff --git a/pygama/pargen/dsp_optimize.py b/pygama/pargen/dsp_optimize.py
index d4ba63fc5..d995b8b2e 100644
--- a/pygama/pargen/dsp_optimize.py
+++ b/pygama/pargen/dsp_optimize.py
@@ -2,9 +2,11 @@
 from .build_processing_chain import build_processing_chain
 from collections import namedtuple
 from pprint import pprint
+import multiprocessing as mp
+from multiprocessing import get_context
 
 
-def run_one_dsp(tb_data, dsp_config, db_dict=None, fom_function=None, verbosity=0):
+def run_one_dsp(tb_data, dsp_config, db_dict=None, fom_function=None, verbosity=0, fom_kwargs=None):
     """
     run one iteration of DSP on tb_data 
 
@@ -28,6 +30,8 @@ def run_one_dsp(tb_data, dsp_config, db_dict=None, fom_function=None, verbosity=
         optimization will be based. Should accept verbosity as a second argument
     verbosity : int (optional)
         verbosity for the processing chain and fom_function calls
+    fom_kwargs:
+        any keyword arguments to pass to the fom
 
     Returns:
     --------
@@ -37,32 +41,31 @@ def run_one_dsp(tb_data, dsp_config, db_dict=None, fom_function=None, verbosity=
         If fom_function is None, returns the output lh5 table for the DSP iteration
     """
     
-    pc, _, tb_out = build_processing_chain(tb_data, dsp_config, db_dict=db_dict, verbosity=verbosity)
+    pc, tb_out = build_processing_chain(tb_data, dsp_config, db_dict=db_dict, verbosity=verbosity)
     pc.execute()
-    if fom_function is not None: return fom_function(tb_out, verbosity)
+    if fom_function is not None: 
+        if fom_kwargs is not None:
+            return fom_function(tb_out, verbosity, fom_kwargs)
+        else: 
+            return fom_function(tb_out, verbosity)
     else: return tb_out
 
 
-
-ParGridDimension = namedtuple('ParGridDimension', 'name i_arg value_strs companions')
+ParGridDimension = namedtuple('ParGridDimension', 'name parameter value_strs')
 
 class ParGrid():
     """ Parameter Grid class
-
     Each ParGrid entry corresponds to a dsp parameter to be varied.
     The ntuples must follow the pattern: 
-    ( name, i_arg, value_strs companions) : ( str, int, list of str, list or None )
-    where name is the name of the dsp routine in dsp_config whose  be
-    optimized, i_arg is the index of the argument to be varied, value_strs is
-    the array of strings to set the argument to, and companions is an optional
-    list of ( name, i_arg, value_strs ) tuples for companion arguments that
-    need to change along with this one
+    ( name parameter value_strs) : ( str, str, list of str)
+    where name and parameter are the same as 'db.name.parameter' in the processing chain,
+    value_strs is the array of strings to set the argument to.
     """
     def __init__(self):
         self.dims = []
 
-    def add_dimension(self, name, i_arg, value_strs, companions = None):
-        self.dims.append( ParGridDimension(name, i_arg, value_strs, companions) )
+    def add_dimension(self, name, parameter, value_strs):
+        self.dims.append( ParGridDimension(name, parameter, value_strs) )
 
     def get_n_dimensions(self):
         return len(self.dims)
@@ -81,10 +84,8 @@ def get_n_grid_points(self):
 
     def get_par_meshgrid(self, copy=False, sparse=False):
         """ return a meshgrid of parameter values
-
         Always uses Matrix indexing (natural for par grid) so that
         mg[i1][i2][...] corresponds to index order in self.dims
-
         Note copy is False by default as opposed to numpy default of True
         """     
         axes = []
@@ -97,11 +98,9 @@ def get_zero_indices(self):
 
     def iterate_indices(self, indices):
         """ iterate given indices [i1, i2, ...] by one.
-
         For easier iteration. The convention here is arbitrary, but its the
         order the arrays would be traversed in a series of nested for loops in
         the order appearin in dims (first dimension is first for loop, etc):
-
         Return False when the grid runs out of indices. Otherwise returns True.
         """
         for iD in reversed(range(self.get_n_dimensions())):
@@ -110,38 +109,43 @@ def iterate_indices(self, indices):
             indices[iD] = 0
         return False
 
+    #def check_indices(self, indices):
+    #    for iD in reversed(range(self.get_n_dimensions())):
+    #        if indices[iD] < self.get_n_points_of_dim(iD): return True
+    #        indices[iD] = 0
+    #    return False
+
     def get_data(self, i_dim, i_par):
         name = self.dims[i_dim].name
-        i_arg = self.dims[i_dim].i_arg
+        parameter = self.dims[i_dim].parameter
         value_str = self.dims[i_dim].value_strs[i_par]
-        companions = self.dims[i_dim].companions
-        return name, i_arg, value_str, companions
+        return name, parameter, value_str
 
     def print_data(self, indices):
         print(f"Grid point at indices {indices}:")
         for i_dim, i_par in enumerate(indices):
-            name, i_arg, value_str, _ = self.get_data(i_dim, i_par)
-            print(f"{name}[{i_arg}] = {value_str}")
+            name, parameter, value_str = self.get_data(i_dim, i_par)
+            print(f"{name}.{parameter} = {value_str}")
 
-    def set_dsp_pars(self, dsp_config, indices):
+    def set_dsp_pars(self, db_dict, indices):        
+        if db_dict is None:
+            db_dict = {}          
         for i_dim, i_par in enumerate(indices):
-            name, i_arg, value_str, companions = self.get_data(i_dim, i_par)
-            dsp_config['processors'][name]['args'][i_arg] = value_str
-            if companions is None: continue
-            for ( c_name, c_i_arg, c_value_str ) in companions:
-               dsp_config['processors'][c_name]['args'][c_i_arg] = c_value_str[i_par]
+            name, parameter, value_str= self.get_data(i_dim, i_par)
+            if name not in db_dict.keys():
+                db_dict[name] = {parameter:value_str}
+            else:
+                db_dict[name][parameter] = value_str        
+        return db_dict
 
 
-def run_grid(tb_data, dsp_config, grid, fom_function, db_dict=None, verbosity=0):
+def run_grid(tb_data, dsp_config, grid, fom_function, db_dict=None, verbosity=1, **fom_kwargs):
     """Extract a table of optimization values for a grid of DSP parameters 
-
     The grid argument defines a list of parameters and values over which to run
     the DSP defined in dsp_config on tb_data. At each point, a scalar
     figure-of-merit is extracted
-
     Returns a N-dimensional ndarray of figure-of-merit values, where the array
     axes are in the order they appear in grid.
-
     Parameters:
     -----------
     tb_data : lh5 Table
@@ -162,6 +166,10 @@ def run_grid(tb_data, dsp_config, grid, fom_function, db_dict=None, verbosity=0)
     verbosity : int (optional)
         verbosity for the processing chain and fom_function calls
 
+    **fom_kwargs : 
+        Any keyword arguments for fom_function
+
+
     Returns:
     --------
     grid_values : ndarray of floats
@@ -169,19 +177,150 @@ def run_grid(tb_data, dsp_config, grid, fom_function, db_dict=None, verbosity=0)
         of the grid argument
     """
 
-    grid_values = np.ndarray(shape=grid.get_shape())
+    grid_values = np.ndarray(shape=grid.get_shape(), dtype='O')
     iii = grid.get_zero_indices()
     if verbosity > 0: print("starting grid calculations...")
-    while True:    
-        grid.set_dsp_pars(dsp_config, iii)
+    while True:
+        db_dict = grid.set_dsp_pars(db_dict, iii)
         if verbosity > 1: pprint(dsp_config)
         if verbosity > 0: grid.print_data(iii)
         grid_values[tuple(iii)] = run_one_dsp(tb_data,
                                               dsp_config,
                                               db_dict=db_dict,
                                               fom_function=fom_function,
-                                              verbosity=verbosity)
+                                              verbosity=verbosity, fom_kwargs=fom_kwargs)
         if verbosity > 0: print("value:", grid_values[tuple(iii)])
         if not grid.iterate_indices(iii): break
     return grid_values
+
+def run_grid_point(tb_data, dsp_config, grids, fom_function, iii, db_dict=None, verbosity=1, fom_kwargs=None):
+    """
+    Runs a single grid point for the index specified
+    """
+    if not isinstance(grids, list):
+        grids = [grids]
+    for index, grid in zip(iii,grids):
+        db_dict = grid.set_dsp_pars(db_dict, index)
+
+    if verbosity > 1: pprint(dsp_config)
+    if verbosity > 0: 
+        [grid.print_data(iii[i]) for i,grid in enumerate(grids)]
+        print(' ')
+    tb_out = run_one_dsp(tb_data,
+                        dsp_config,
+                        db_dict=db_dict,
+                        verbosity=verbosity)
+    res = np.ndarray(shape=len(grids), dtype='O')
+    if fom_function:
+        for i in range(len(grids)):
+            if fom_kwargs[i] is not None:
+                if len(fom_function)>1:
+                    res[i] = fom_function[i](tb_out, verbosity, fom_kwargs[i])
+                else:
+                    res[i] = fom_function[0](tb_out, verbosity, fom_kwargs[i])
+            else:
+                if len(fom_function)>1:
+                    res[i] = fom_function[i](tb_out, verbosity)
+                else:
+                    res[i] = fom_function[0](tb_out, verbosity)
+        print("value:",res)
+        out = {'indexes': [tuple(ii) for ii in iii], 'results': res}
+
+    else: 
+        out = {'indexes': [tuple(ii) for ii in iii], 'results': tb_out}
+    return out
+
+def get_grid_points(grid):
+    """
+    Generates a list of the indices of all possible grid points
+    """
+    out = []
+    iii = [gri.get_zero_indices() for gri in grid]
+    complete =np.full(len(grid), False)
+    while True:
+        out.append([tuple(ii) for ii in iii])
+        
+        for i, gri in enumerate(zip(iii,grid)):
+            if not gri[1].iterate_indices(gri[0]):
+                print(f"{i} grid end")
+                iii[i]=gri[1].get_zero_indices()
+                complete[i]=True
+        if all(complete): break
+    return out
+
+def run_grid_multiprocess_parallel(tb_data, dsp_config, grid, fom_function, db_dict=None, verbosity=1, 
+                          processes=5, fom_kwargs=None):
+
+    """
+    run one iteration of DSP on tb_data with multiprocessing, can handle multiple grids if they are the same dimensions
+
+    Optionally returns a value for optimization
+
+    Parameters:
+    -----------
+    tb_data : lh5 Table
+        An input table of lh5 data. Typically a selection is made prior to
+        sending tb_data to this function: optimization typically doesn't have to
+        run over all data
+    dsp_config : dict
+        Specifies the DSP to be performed for this iteration (see
+        build_processing_chain()) and the list of output variables to appear in
+        the output table
+    grid: pargrid, list of pargrids
+        Grids to run optimization on
+    db_dict : dict (optional)
+        DSP parameters database. See build_processing_chain for formatting info
+    fom_function : function or None (optional)
+        When given the output lh5 table of this DSP iteration, the
+        fom_function must return a scalar figure-of-merit value upon which the
+        optimization will be based. Should accept verbosity as a second argument. 
+        If multiple grids provided can either pass one fom to have it run for each grid 
+        or a list of fom to run different fom on each grid.
+    verbosity : int (optional)
+        verbosity for the processing chain and fom_function calls
+    fom_kwargs: 
+        any keyword arguments to pass to the fom, 
+        if multiple grids given will need to be a list of the fom_kwargs for each grid
+
+    Returns:
+    --------
+    figure_of_merit : float
+        If fom_function is not None, returns figure-of-merit value for the DSP iteration
+    tb_out : lh5 Table
+        If fom_function is None, returns the output lh5 table for the DSP iteration
+    """
+
+    if not isinstance(grid, list):
+        grid = [grid]
+    if not isinstance(fom_function, list) and fom_function is not None:
+        fom_function = [fom_function]
+    if not isinstance(fom_kwargs, list):
+        fom_kwargs = [fom_kwargs for gri in grid]
+    grid_values = []
+    shapes = [gri.get_shape() for gri in grid]
+    if fom_function is not None:
+        for i in range(len(grid)):
+            grid_values.append(np.ndarray(shape = shapes[i], dtype='O'))
+    else:
+        grid_lengths = np.array([gri.get_n_grid_points() for gri in grid])
+        grid_values.append(np.ndarray(shape = shapes[np.argmax(grid_lengths)], dtype='O'))
+    grid_list = get_grid_points(grid)
+    pool = mp.Pool(processes=processes)
+    results = [pool.apply_async(run_grid_point, args=(tb_data,dsp_config, grid, fom_function, np.asarray(gl),
+                    db_dict, verbosity, fom_kwargs)) for gl in grid_list]
+
+    for result in results:
+        res = result.get()
+        indexes = res['indexes']
+        if fom_function is not None:
+            for i in range(len(grid)):
+                index = indexes[i]
+                if grid_values[i][index] is None:
+                    grid_values[i][index] = res['results'][i]
+        else:
+            grid_values[0][indexes[0]] = {f'{indexes[0]}':res['results']}
+
         
+    pool.close()
+    pool.join()
+    return grid_values
\ No newline at end of file
diff --git a/pygama/pargen/ecal_th.py b/pygama/pargen/ecal_th.py
new file mode 100644
index 000000000..f7357e1c8
--- /dev/null
+++ b/pygama/pargen/ecal_th.py
@@ -0,0 +1,248 @@
+import pygama.lh5 as lh5
+import matplotlib.pyplot as plt
+import numpy as np
+import os,json
+import pathlib
+from scipy.optimize import curve_fit
+#import pygama.genpar_tmp.cuts as cut
+import pygama.analysis.histograms as pgh
+import pygama.analysis.calibration as cal
+import pygama.analysis.peak_fitting as pgf
+import scipy.stats
+import math
+
+def fwhm_slope(x, m0, m1):
+    """
+    Fit the energy resolution curve
+    """
+    return np.sqrt(m0 + m1*x)
+
+
+def energy_cal_th(files, energy_params, save_path):
+
+
+    """
+    This is an example script for calibrating Th data.
+    """
+    
+    if isinstance(energy_params, str): energy_params = [energy_params]
+
+    ####################
+    # Start the analysis
+    ####################
+    print('Load and apply quality cuts...',end=' ')
+    uncal_pass = lh5.load_nda(files,'raw',energy_params)
+    print("Done")
+
+    Nevents = len(uncal_pass[energy_params[0]])
+    print(f'{Nevents} events pass')
+    
+    glines    = [583.191, 727.330, 860.564,1592.53,1620.50,2103.53,2614.50] # gamma lines used for calibration
+    range_keV = [(15,15),(20,20), (30,30),(35,25),(25,40),(40,40),(70,70)] # side bands width
+    funcs = [pgf.extended_gauss_step_pdf,pgf.extended_gauss_step_pdf,pgf.extended_radford_pdf,pgf.extended_radford_pdf,
+         pgf.extended_radford_pdf,pgf.extended_radford_pdf,pgf.extended_radford_pdf]
+    gof_funcs = [pgf.gauss_step_pdf,pgf.gauss_step_pdf,pgf.radford_pdf,pgf.radford_pdf,
+            pgf.radford_pdf,pgf.radford_pdf,pgf.radford_pdf]
+    output_dict = {}
+    for energy_param in energy_params:
+        kev_ranges = range_keV.copy()
+        guess_keV  = (2620/np.nanpercentile(uncal_pass[energy_param],99))
+        print(f'Find peaks and compute calibration curve for {energy_param}', end = ' ')
+        pars, cov, results = cal.hpge_E_calibration(uncal_pass[energy_param],
+                                                    glines,
+                                                    guess_keV,
+                                                    deg=1,
+                                                    range_keV = range_keV,
+                                                    funcs = funcs,
+                                                    gof_funcs = gof_funcs,
+                                                    simplex=True,
+                                                    verbose=False
+                                                    )
+        pk_pars      = results['pk_pars']
+        found_peaks = results['got_peaks_locs']
+        fitted_peaks = results['fitted_keV']
+        
+        for i, peak in enumerate(glines):
+            if peak not in fitted_peaks: 
+                kev_ranges[i] = (kev_ranges[i][0]-5,  kev_ranges[i][1]-5)
+        for i, peak in enumerate(glines):
+            if peak not in fitted_peaks: 
+                kev_ranges[i] = (kev_ranges[i][0]-5,  kev_ranges[i][1]-5)
+        for i, peak in enumerate(fitted_peaks):
+            if results['pk_fwhms'][:,1][i]/results['pk_fwhms'][:,0][i] >0.05:
+                index = np.where(glines == peak)[0][0]
+                kev_ranges[i] = (kev_ranges[index][0]-5,  kev_ranges[index][1]-5)
+
+        pars, cov, results = cal.hpge_E_calibration(uncal_pass[energy_param],
+                                                    glines,
+                                                    guess_keV,
+                                                    deg=1,
+                                                    range_keV = kev_ranges,
+                                                    funcs = funcs,
+                                                    gof_funcs = gof_funcs,
+                                                    simplex=True,
+                                                    verbose=False
+                                                    )
+        print("done")
+        print(" ")
+        fitted_peaks = results['fitted_keV']
+        fitted_funcs = []
+        fitted_gof_funcs = []
+        for i, peak in enumerate(glines):
+            if peak in fitted_peaks: 
+                fitted_funcs.append(funcs[i])
+                fitted_gof_funcs.append(gof_funcs[i])
+                
+        ecal_pass = pgf.poly(uncal_pass[energy_param], pars)
+        xpb = 1
+        xlo = 0
+        xhi = 4000
+        nb = int((xhi-xlo)/xpb)
+        hist_pass, bin_edges = np.histogram(ecal_pass, range=(xlo, xhi), bins=nb)
+        bins_pass = pgh.get_bin_centers(bin_edges)
+        fitted_peaks = results['fitted_keV']
+        pk_pars      = results['pk_pars']
+        pk_covs      = results['pk_covs']
+        datatype, detector, measurement, run, timestamp = os.path.basename(files[0]).split('-')
+        plot_title = f'{detector}-{measurement}-{run}'
+        peaks_kev = results['got_peaks_keV']
+        
+
+        pk_ranges = results['pk_ranges']
+        p_vals = results['pk_pvals']
+        mus = [pgf.get_mu_func(func_i, pars_i) for func_i, pars_i in zip(fitted_funcs, pk_pars)]
+
+        fwhms        = results['pk_fwhms'][:,0]
+        dfwhms       = results['pk_fwhms'][:,1]
+
+
+        plt.rcParams['figure.figsize'] = (12, 20)
+        plt.rcParams['font.size'] = 12
+
+        plt.figure()
+        range_adu = 5/pars[0] #10keV window around peak in adu
+        for i, peak in enumerate(mus):
+            plt.subplot(math.ceil((len(mus))/2),2,i+1)
+            binning = np.arange(pk_ranges[i][0], pk_ranges[i][1], 1)
+            bin_cs = (binning[1:]+binning[:-1])/2
+            energies = uncal_pass[energy_param][(uncal_pass[energy_param]> pk_ranges[i][0])&
+                                          (uncal_pass[energy_param]< pk_ranges[i][1])][:15000]
+
+            counts, bs, bars = plt.hist(energies, bins=binning, histtype='step')
+            fit_vals = fitted_gof_funcs[i](bin_cs, *pk_pars[i])*np.diff(bs)
+            plt.plot(bin_cs, fit_vals)
+            plt.step(bin_cs, [(fval-count)/count if count != 0 else  (fval-count) for count, fval in zip(counts, fit_vals)] ) 
+            locs,labels = plt.xticks()
+            new_labels = get_peak_labels(locs, pars)
+            plt.xticks(ticks = locs[1:-1], labels = new_labels)
+
+            plt.plot([bin_cs[10]],[0],label=get_peak_label(fitted_peaks[i]), linestyle='None' )
+            plt.plot([bin_cs[10]],[0],label = f'{fitted_peaks[i]:.1f} keV', linestyle='None')
+            plt.plot([bin_cs[10]],[0],label = f'{fwhms[i]:.2f} +- {dfwhms[i]:.2f} keV', linestyle='None')
+            plt.plot([bin_cs[10]],[0],label = f'p-value : {p_vals[i]:.2f}', linestyle='None')
+
+            plt.xlabel('Energy (keV)')
+            plt.ylabel('Counts')
+            plt.legend(loc = 'upper left', frameon=False)
+            plt.xlim([peak-range_adu, peak+range_adu])
+
+        plt.tight_layout()
+        fits_save_path = os.path.join(save_path, 'plots', detector, f'{energy_param}_fits.pdf')
+        pathlib.Path(os.path.dirname(fits_save_path)).mkdir(parents=True, exist_ok=True)
+        plt.savefig(fits_save_path)
+        plt.close()
+
+        #####
+        # Remove the Tl SEP and DEP from calibration if found
+        fwhm_peaks   = np.array([], dtype=np.float32)
+        indexes=[]
+        for i,peak in enumerate(fitted_peaks):
+            if(peak==2103.53): 
+                print(f"Tl SEP found at index {i}")
+                indexes.append(i)
+                continue
+            elif(peak==1592.53): 
+                print(f"Tl DEP found at index {i}")
+                indexes.append(i)
+                continue
+            else:
+                fwhm_peaks = np.append(fwhm_peaks,peak)
+        fwhms  = np.delete(fwhms,[indexes])
+        dfwhms = np.delete(dfwhms,[indexes])
+        mus = np.delete(mus,[indexes])
+        #####
+        plt.rcParams['figure.figsize'] = (12, 8)
+        plt.rcParams['font.size'] = 8
+        for i,peak in enumerate(fwhm_peaks):
+            print(f'FWHM of {peak} keV peak is: {fwhms[i]:1.2f} +- {dfwhms[i]:1.2f} keV')
+        param_guess  = [0.2,0.001]
+        param_bounds = (0, [10., 1.])
+        fit_pars, fit_covs = curve_fit(fwhm_slope, fwhm_peaks, fwhms, sigma=dfwhms, 
+                               p0=param_guess, bounds=param_bounds, absolute_sigma=True)
+        sderrs = np.sqrt(np.diag(fit_covs))
+        qbb_err = fwhm_slope(2039.0,*(fit_pars+sderrs))-fwhm_slope(2039.0,*fit_pars)
+        print(f'FWHM curve fit: {fit_pars}')
+        fit_vals = fwhm_slope(fwhm_peaks,*fit_pars)
+        print(f'FWHM fit values: {fit_vals}')
+        fit_qbb = fwhm_slope(2039.0,*fit_pars)
+        print(f'FWHM energy resolution at Qbb: {fit_qbb:1.2f} +- {qbb_err:1.2f} keV' )
+        qbb_line_hx = [2039.0,2039.0]
+        qbb_line_hy = [np.amin(fwhms),fit_qbb]
+        qbb_line_vx = [np.amin(fwhm_peaks),2039.0]
+        qbb_line_vy = [fit_qbb,fit_qbb]
+        fig, (ax1, ax2) = plt.subplots(2, 1, constrained_layout=True, sharex=True)
+        ax1.errorbar(fwhm_peaks,fwhms,yerr=dfwhms, marker='x',lw=0, c='b')
+        ax1.plot(fwhm_peaks,fit_vals,ls=' ')
+        fwhm_slope_bins = np.arange(np.amin(fwhm_peaks),np.amax(fwhm_peaks),10)
+        ax1.plot(fwhm_slope_bins ,fwhm_slope(fwhm_slope_bins,*fit_pars),lw=1, c='g')
+        ax1.plot(qbb_line_hx,qbb_line_hy,lw=1, c='r')
+        ax1.plot(qbb_line_vx,qbb_line_vy,lw=1, c='r')
+        ax1.set_ylim([1,3])
+        ax1.set_ylabel("FWHM energy resolution (keV)", ha='right', y=1)
+        ax2.plot(fwhm_peaks,pgf.poly(mus, pars)-fwhm_peaks, lw=1, c='b')
+        ax2.set_xlabel("Energy (keV)",    ha='right', x=1)
+        ax2.set_ylabel("Residuals (keV)", ha='right', y=1)
+        fig.suptitle(plot_title)
+        plot_save_path = os.path.join(save_path, 'plots', detector, f'{energy_param}.png')
+        pathlib.Path(os.path.dirname(plot_save_path)).mkdir(parents=True, exist_ok=True)
+        fig.savefig(plot_save_path, bbox_inches='tight')
+        plt.close()
+
+        
+
+        output_dict[energy_param] = {'Qbb_fwhm': round(fit_qbb,2), 'Qbb_fwhm_err': round(qbb_err,2),
+                                    '2.6_fwhm': round(fwhms[-1],2), '2.6_fwhm_err': round(dfwhms[-1],2), 
+                                    "m0":fit_pars[0], "m1":fit_pars[1], 
+                                    "Calibration_pars":pars.tolist(),
+                                    "Number_passed": Npass,'Number_cut': Ncut,"Cut Percentage": Ratio 
+
+                                    }
+    dict_save_path = os.path.join(save_path, f'{detector}.json')
+    with open(dict_save_path,'w') as fp:
+        json.dump(output_dict,fp, indent=4)
+
+
+def get_peak_labels(labels, pars):
+    out = np.array([])
+    for i,label in enumerate(labels):
+        if i == 0 or i == len(labels)-1:
+            pass
+        else:
+            out = np.append(out, f'{pgf.poly(label, pars):.1f}')
+    return out
+
+def get_peak_label(peak):
+    if peak == 583.191: 
+        return 'Tl 583'
+    elif peak == 727.33:
+        return 'Bi 727'
+    elif peak == 860.564:
+        return 'Tl 860'
+    elif peak == 1592.53:
+        return 'Tl DEP'
+    elif peak == 1620.5:
+        return 'Bi FEP'
+    elif peak == 2103.53:
+        return 'Tl SEP'
+    elif peak == 2614.5:
+        return 'Tl FEP'
\ No newline at end of file
diff --git a/pygama/pargen/energy_cal.py b/pygama/pargen/energy_cal.py
index 75dfe7365..534ba36a7 100644
--- a/pygama/pargen/energy_cal.py
+++ b/pygama/pargen/energy_cal.py
@@ -1,27 +1,25 @@
 """
 routines for automatic calibration.
-- peakdet (useful to find maxima in an array without taking derivative)
-- get_most_prominent_peaks (find by looking for spikes in spectrum derivative)
-- match_peaks (identify peaks based on ratios between known gamma energies)
-- calibrate_tl208 (main routine -- fits multiple peaks w/ Radford peak shape)
-- get_calibration_energies (a good place to put pk energies)
+- hpge_find_E_peaks (Find uncalibrated E peaks whose E spacing matches the pattern in peaks_keV)
+- hpge_get_E_peaks (Get uncalibrated E peaks at the energies of peaks_keV)
+- hpge_fit_E_peaks (fits the energy peals)
+- hpge_E_calibration (main routine -- finds and fits peaks specified)
 """
 import sys
 import numpy as np
 import pygama.math.utils as pgu
-import pygama.math.peak_fitting as pgp
+import pygama.math.peak_fitting as pgf
 import pygama.math.histogram as pgh
 import matplotlib.pyplot as plt
 import matplotlib.gridspec as gs
 from scipy.signal import medfilt, find_peaks_cwt
+import scipy.stats
 
 def hpge_find_E_peaks(hist, bins, var, peaks_keV, n_sigma=5, deg=0, Etol_keV=None, var_zero=1, verbose=False):
     """ Find uncalibrated E peaks whose E spacing matches the pattern in peaks_keV
-
     Note: the specialization here to units "keV" in peaks and Etol is
     unnecessary. However it is kept so that the default value for Etol_keV has
     an unambiguous interpretation.
-
     Parameters
     ----------
     hist, bins, var: array, array, array
@@ -39,7 +37,6 @@ def hpge_find_E_peaks(hist, bins, var, peaks_keV, n_sigma=5, deg=0, Etol_keV=Non
         number used to replace zeros of var to avoid divide-by-zero in
         hist/sqrt(var). Default value is 1. Usually when var = 0 its because
         hist = 0, and any value here is fine.
-
     Returns
     -------
     detected_peak_locations : list
@@ -79,7 +76,6 @@ def hpge_find_E_peaks(hist, bins, var, peaks_keV, n_sigma=5, deg=0, Etol_keV=Non
 
 def hpge_get_E_peaks(hist, bins, var, cal_pars, peaks_keV, n_sigma=3, Etol_keV=5, var_zero=1, verbose = False):
     """ Get uncalibrated E peaks at the energies of peaks_keV
-
     Parameters
     ----------
     hist, bins, var: array, array, array
@@ -97,7 +93,6 @@ def hpge_get_E_peaks(hist, bins, var, cal_pars, peaks_keV, n_sigma=3, Etol_keV=5
         number used to replace zeros of var to avoid divide-by-zero in
         hist/sqrt(var). Default value is 1. Usually when var = 0 its because
         hist = 0, and any value here is fine.
-
     Returns
     -------
     got_peak_locations : list
@@ -147,9 +142,8 @@ def hpge_get_E_peaks(hist, bins, var, cal_pars, peaks_keV, n_sigma=3, Etol_keV=5
 
 
 def hpge_fit_E_peak_tops(hist, bins, var, peak_locs, n_to_fit=7,
-                         poissonLL=False, inflate_errors=False, gof_method='var'):
+                         cost_func = 'Least Squares', inflate_errors=False, gof_method='var'):
     """ Fit gaussians to the tops of peaks
-
     Parameters
     ----------
     hist, bins, var: array, array, array
@@ -164,7 +158,6 @@ def hpge_fit_E_peak_tops(hist, bins, var, peak_locs, n_to_fit=7,
         Flag passed to gauss_mode_width_max()
     gof_method : str (optional)
         method flag passed to gauss_mode_width_max()
-
     Returns
     -------
     pars_list : list of array
@@ -176,10 +169,10 @@ def hpge_fit_E_peak_tops(hist, bins, var, peak_locs, n_to_fit=7,
     cov_list = []
     for E_peak in peak_locs:
         try:
-            pars, cov = pgp.gauss_mode_width_max(hist, bins, var,
+            pars, cov = pgf.gauss_mode_width_max(hist, bins, var,
                                                  mode_guess=E_peak,
                                                  n_bins=n_to_fit,
-                                                 poissonLL=poissonLL,
+                                                 cost_func=cost_func,
                                                  inflate_errors=inflate_errors,
                                                  gof_method=gof_method)
         except: pars, cov = None, None
@@ -191,7 +184,6 @@ def hpge_fit_E_peak_tops(hist, bins, var, peak_locs, n_to_fit=7,
 
 def get_hpge_E_peak_par_guess(hist, bins, var, func):
     """ Get parameter guesses for func fit to peak in hist
-
     Parameters
     ----------
     hist, bins, var: array, array, array
@@ -200,83 +192,64 @@ def get_hpge_E_peak_par_guess(hist, bins, var, func):
     func : function
         The function to be fit to the peak in the (windowed) hist
     """
-    if func == pgp.gauss_step:
-        # pars are: amp, mu, sigma, bkg, step
-        # get mu and hieght from a gaus fit
-        pars, cov = pgf.gauss_mode_max(hist, bins, var)
+    if  func == pgf.gauss_step_cdf or func == pgf.gauss_step_pdf or func == pgf.extended_gauss_step_pdf:
+        # get mu and hieght from a gauss fit, also sigma as fallback
+        pars, cov = pgf.gauss_mode_width_max(hist, bins, var)
+        bin_centres = pgh.get_bin_centers(bins)
         if pars is None:
-            print("get_hpge_E_peak_par_guess: gauss_mode_max failed")
-            return []
-        mu = pars[0]
-        height = pars[1]
+            print("get_hpge_E_peak_par_guess: gauss_mode_width_max failed")
+            i_0 = np.argmax(hist)
+            mu = bin_centres[i_0]
+            height = hist[i_0]
+            sigma_guess=None
+        else:
+            mu = pars[0]
+            sigma_guess = pars[1]
+            height = pars[2]
 
         # get bg and step from edges of hist
-        bg = np.sum(hist[-5:])/5
-        step = np.sum(hist[:5])/5 - bg
-
+        bg = np.mean(hist[-10:])
+        step = (bg - np.mean(hist[:10]))
         # get sigma from fwfm with f = 1/sqrt(e)
         try:
-            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg+step/2, method='interpolate')[0]
+            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg-step/2, method='interpolate')[0]
             if sigma == 0: raise ValueError
         except:
-            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg+step/2, method='fit_slopes')[0]
+            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg-step/2, method='fit_slopes')[0]
             if sigma == 0:
                 print("get_hpge_E_peak_par_guess: sigma estimation failed")
-                return []
+                if sigma_guess is not None:
+                    sigma = sigma_guess
+                else:
+                    return []
 
         # now compute amp and return
-        height -= (bg + step/2)
-        amp = height * sigma * np.sqrt(2 * np.pi)
-        return [amp, mu, sigma, bg, step]
+        n_sig = np.sum(hist[(bin_centres>mu-3*sigma)&(bin_centres<mu+3*sigma)]) 
+        n_bkg = np.sum(hist)-n_sig
+        
+        hstep = step /(bg + np.mean(hist[:10]))
+        return [n_sig, mu, sigma/2,n_bkg, hstep,bins[0], bins[-1],0]
 
-    if func == pgp.radford_peak:
-        # pars are: mu, sigma, hstep, htail, tau, bg0, amp
+    if  func == pgf.radford_cdf or func == pgf.radford_pdf or func == pgf.extended_radford_pdf:
 
         #guess mu, height
-        i_0 = np.argmax(hist)
-        mu = pgh.get_bin_centers(bins)[i_0]
-        height = hist[i_0]
-
-        # get bg and step from edges of hist
-        bg0 = np.sum(hist[-5:])/5
-        step = np.sum(hist[:5])/5 - bg0
-
-        # get sigma from fwfm with f = 1/sqrt(e)
-        try:
-            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg0+step/2, method='interpolate')[0]
-            if sigma == 0: raise ValueError
-        except:
-            sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg0+step/2, method='fit_slopes')[0]
-            if sigma == 0: print("get_hpge_E_peak_par_guess: sigma estimation failed")
-            return []
-        sigma = sigma*.5 # roughly remove some amount due to tail
-
-        # for now hard-coded
-        htail = 1./5
-        tau = 6.*sigma
-
-        # now compute amp and return
-        height -= (bg0 + step/2)
-        amp = height / (htail*0.87/35 + (1-htail)/(sigma*np.sqrt(2*np.pi))) #numerical factors from definition of tail_func @ mu
-
-        hstep = step/(2*amp)
-
-        parguess = [mu, sigma, hstep, htail, tau, bg0, amp]
-
-        return parguess
-    if func == pgp.radford_peak_wrapped:
-        # pars are: mu, sigma, hstep, htail, tau, bg0, amp
-        # get mu and height from a gaus fit
-        #pars, cov = pgf.gauss_mode_max(hist, bins, var)
+        pars, cov = pgf.gauss_mode_width_max(hist, bins, var)
+        bin_centres = pgh.get_bin_centers(bins)
+        if pars is None:
+            print("get_hpge_E_peak_par_guess: gauss_mode_width_max failed")
+            sigma_guess=None
 
-        #guess mu, height
+        else:
+            sigma_guess = pars[1]
+            #mu=pars[0]
+            #height=pars[2]
         i_0 = np.argmax(hist)
-        mu = pgh.get_bin_centers(bins)[i_0]
+        mu = bin_centres[i_0]
         height = hist[i_0]
 
         # get bg and step from edges of hist
-        bg0 = np.sum(hist[-5:])/5
-        step = np.sum(hist[:5])/5 - bg0
+        bg0 = np.mean(hist[-10:])
+        step = bg0 -np.mean(hist[:10]) 
 
         # get sigma from fwfm with f = 1/sqrt(e)
         try:
@@ -284,88 +257,53 @@ def get_hpge_E_peak_par_guess(hist, bins, var, func):
             if sigma == 0: raise ValueError
         except:
             sigma = pgh.get_fwfm(0.6065, hist, bins, var, mx=height, bl=bg0+step/2, method='fit_slopes')[0]
-            if sigma == 0: print("get_hpge_E_peak_par_guess: sigma estimation failed")
-            return []
-        sigma = sigma*.5 # roughly remove some amount due to tail
+            if sigma == 0: 
+                print("get_hpge_E_peak_par_guess: sigma estimation failed")
+                if sigma_guess is not None:
+                    sigma = sigma_guess
+                else:
+                    return []
+        sigma = sigma*.6 # roughly remove some amount due to tail
 
         # for now hard-coded
         htail = 1./5
-        tau = 6.*sigma
+        tau = 0.5*sigma
+        
+        hstep = step /(bg0 + np.mean(hist[:10]))
+        
+        n_sig = np.sum(hist[(bin_centres>mu-3*sigma)&(bin_centres<mu+3*sigma)]) 
+        n_bkg = np.sum(hist)-n_sig
 
-        # now compute amp and return
-        height -= (bg0 + step/2)
-        amp = height / (htail*0.87/35 + (1-htail)/(sigma*np.sqrt(2*np.pi))) #numerical factors from definition of tail_func @ mu
-
-        hstep = step/(2*amp)
-
-        # convert to wrapped parameters
-        A = amp*(1-htail)
-        S = amp*2*hstep
-        T = amp*htail
-
-        parguess = [A, mu, sigma, bg0, S, T, tau]
+        parguess = [n_sig,  mu, sigma, htail, tau, n_bkg,hstep,bins[0], bins[-1],0]
 
         return parguess
+
     else:
         print(f'get_hpge_E_peak_par_guess not implemented for {func.__name__}')
         return []
+        
 
-def get_hpge_E_peak_bounds(hist, bins, var, func, pars_guess):
+def get_hpge_E_fixed( func):
     """
-    Returns: Sequence of (min, max) pairs for each element in x.
+    Returns: Sequence list of fixed indexes for fitting and mask for parameters
     """
-    if not pars_guess:
-        print(f'non-zero pars_guess must be passed to get_hpge_E_peak_bounds')
-        return None
-
-    if func == pgf.gauss_step:
-        # pars are: amp, mu, sigma, bkg, step
-        amp, mu, sigma, bkg, step = pars_guess
-
-        ampLims = [0, 5*amp]
-        muLims = [mu - 10*sigma, mu + 10*sigma]
-        sigmaLims = [0, 10*sigma]
-        bkgLims = [0, amp]
-        stepLims = [-amp, amp]
-
-        return list(zip(ampLims, muLims, sigmaLims, bkgLims, stepLims))
-
-    if func == pgp.radford_peak:
-        # pars are: mu, sigma, hstep, htail, tau, bg0, amp
-        mu, sigma, hstep, htail, tau, bg0, amp = pars_guess
 
-        muLims = [mu - 10*sigma, mu + 10*sigma]
-        sigmaLims = [0, 10*sigma]
-        hstepLims = [-1, 1]
-        htailLims = [0, 1]
-        tauLims = [sigma, 100*sigma]
-        bg0Lims = [0, amp]
-        ampLims = [0, 5*amp]
+    if  func ==  pgf.gauss_step_cdf or func ==  pgf.gauss_step_pdf or func == pgf.extended_gauss_step_pdf :
+        # pars are: n_sig, mu, sigma, n_bkg, hstep, components
+        return [5,6,7] , np.array([True, True, True,True,True,False,False,False])
 
-        return list(zip(muLims, sigmaLims, hstepLims, htailLims, tauLims, bg0Lims, ampLims))
+    if  func == pgf.radford_cdf or func == pgf.radford_pdf or func == pgf.extended_radford_pdf:
+        # pars are: n_sig, mu, sigma, htail,tau, n_bkg, hstep, components
+        return [7,8,9], np.array([True, True, True,True,True,True,True,False,False,False])
 
-    if func == pgf.radford_peak_wrapped:
-
-        # pars are: A, mu, sigma, bg0, S, T, tau
-        A, mu, sigma, bg0, S, T, tau = pars_guess
-
-        ALims = [0, 5*A]
-        muLims = [mu - 10*sigma, mu + 10*sigma]
-        sigmaLims = [0, 10*sigma]
-        bg0Lims = [0, A]
-        SLims = [-5*A, 5*A]
-        TLims = [0, 5*A]
-        tauLims = [sigma, 100*sigma]
-
-        return list(zip(ALims, muLims, sigmaLims, bg0Lims, SLims, TLims, tauLims))
     else:
-        print(f'get_hpge_E_peak_bounds not implemented for {func.__name__}')
+        print(f'get_hpge_E_fixed not implemented for {func.__name__}')
         return None
     return None
 
-def hpge_fit_E_peaks(E_uncal, mode_guesses, wwidths, n_bins=50, funcs=pgp.gauss_step, uncal_is_int=False, getbounds=True):
-    """ Fit gaussians to the tops of peaks
-
+def hpge_fit_E_peaks(E_uncal, mode_guesses, wwidths, n_bins=50, funcs=pgf.gauss_step_cdf, 
+                     method = 'unbinned', gof_funcs=None, uncal_is_int=False, simplex=False):
+    """ Fit the Energy peaks specified using the function given
     Parameters
     ----------
     E_uncal : array
@@ -379,12 +317,14 @@ def hpge_fit_E_peaks(E_uncal, mode_guesses, wwidths, n_bins=50, funcs=pgp.gauss_
         array of number of bins to use for the fit window histogramming
     funcs : function or array of functions
         funcs to be used to fit each region
+    method: str 
+            default is unbinned fit can specify to use binned fit method instead
+    gof_funcs : function or array of functions
+            functions to use for calculation goodness of fit if unspecified will use same func as fit
     uncal_is_int : bool
         if True, attempts will be made to avoid picket-fencing when binning
         E_uncal
-    getbounds : bool or array of bools determining whether to pass bounds
-        to scipy.optimize.minimize via pgf.fit_hist for each peak
-
+    simplex : bool determining whether to do a round of simpson minimisation before gradient minimisation
     Returns
     -------
     pars : list of array
@@ -400,6 +340,8 @@ def hpge_fit_E_peaks(E_uncal, mode_guesses, wwidths, n_bins=50, funcs=pgp.gauss_
     covs = []
     binws = []
     ranges = []
+    errors=[]
+    p_vals = []
 
     for i_peak in range(len(mode_guesses)):
         # get args for this peak
@@ -408,46 +350,83 @@ def hpge_fit_E_peaks(E_uncal, mode_guesses, wwidths, n_bins=50, funcs=pgp.gauss_
         func_i = funcs[i_peak] if hasattr(funcs, '__len__') else funcs
         wleft_i = wwidth_i/2 if np.isscalar(wwidth_i) else wwidth_i[0]
         wright_i = wwidth_i/2 if np.isscalar(wwidth_i) else wwidth_i[1]
-        getbounds_i = getbounds if np.isscalar(getbounds) else getbounds[1]
-
-        # bin a histogram
-        Euc_min = mode_guesses[i_peak] - wleft_i
-        Euc_max = mode_guesses[i_peak] + wright_i
-        Euc_min, Euc_max, n_bins_i = pgh.better_int_binning(x_lo=Euc_min, x_hi=Euc_max, n_bins=n_bins_i)
-        hist, bins, var = pgh.get_hist(E_uncal, bins=n_bins_i, range=(Euc_min,Euc_max))
-
-        # get parameters guesses
-        par_guesses = get_hpge_E_peak_par_guess(hist, bins, var, func_i)
-        if getbounds_i:
-            bounds = get_hpge_E_peak_bounds(hist, bins, var, func_i, par_guesses)
+        if gof_funcs is not None:
+            gof_func_i = gof_funcs[i_peak] if hasattr(gof_funcs, '__len__') else gof_funcs
         else:
-            bounds = None
-
+            gof_func_i = func_i
+        
         try:
-            pars_i, cov_i = pgp.fit_hist(func_i, hist, bins, var=var, guess=par_guesses, bounds=bounds)
-
-            if sum([sum(c) if c is not None else 0 for c in cov_i]) == np.inf or sum([sum(c) if c is not None else 0 for c in cov_i]) == 0 or (np.sqrt(np.diagonal(cov_i))/pars_i < 1e-7).any():
+            # bin a histogram
+            Euc_min = mode_guesses[i_peak] - wleft_i
+            Euc_max = mode_guesses[i_peak] + wright_i
+            Euc_min, Euc_max, n_bins_i = pgh.better_int_binning(x_lo=Euc_min, x_hi=Euc_max, n_bins=n_bins_i)
+            
+            if method =='unbinned':
+                energies = E_uncal[(E_uncal>Euc_min)&(E_uncal<Euc_max)][:15000]
+                hist, bins, var = pgh.get_hist(energies, bins=n_bins_i, range=(Euc_min,Euc_max))
+                par_guesses = get_hpge_E_peak_par_guess(hist, bins, var, func_i)
+
+                fixed, mask = get_hpge_E_fixed(func_i)
+
+                pars_i,errs_i, cov_i = pgf.fit_unbinned(func_i,energies ,
+                                        guess=par_guesses, Extended=True, fixed=fixed,simplex=simplex)
+            else:
+                hist, bins, var = pgh.get_hist(E_uncal, bins=n_bins_i, range=(Euc_min,Euc_max))
+
+                # get parameters guesses
+                par_guesses = get_hpge_E_peak_par_guess(hist, bins, var, func_i)
+                bounds = get_hpge_E_peak_bounds(hist, bins, var, func_i, par_guesses)
+                fixed, mask = get_hpge_E_fixed(func_i)
+                pars_i,errs_i, cov_i = pgf.fit_binned(func_i, hist,  bins, var=var, 
+                                        guess=par_guesses, cost_func=method, Extended=True, 
+                                                    fixed=fixed, simplex=simplex, bounds = bounds)
+            
+            csqr = pgf.goodness_of_fit(hist, bins, None, gof_func_i, pars_i , method='LR')
+            p_val = scipy.stats.chi2.sf(csqr[0], csqr[1])
+
+            
+            pars_i = np.array(pars_i)[mask]
+            errs_i = np.array(errs_i)[mask]
+            cov_i = np.array(cov_i)[mask,:][:,mask]
+            
+            n_events = pgf.get_total_events_func(func_i, pars_i, errors=errs_i)
+            if (sum([sum(c) if c is not None else 0 for c in cov_i]) == np.inf or 
+                sum([sum(c) if c is not None else 0 for c in cov_i]) == 0 or 
+                np.isnan(sum([sum(c) if c is not None else 0 for c in cov_i]))) :
                 print(f'hpge_fit_E_peaks: cov estimation failed for i_peak={i_peak} at loc {mode_guesses[i_peak]:g}')
-                pars_i, cov_i = None, None
-
-        except: pars_i, cov_i = None, None
-
+                pars_i, errs_i, cov_i, p_val = None, None, None, None
+            
+            elif (np.abs(errs_i/pars_i) < 1e-7).any():
+                print(f'hpge_fit_E_peaks: cov estimation failed for i_peak={i_peak} at loc {mode_guesses[i_peak]:g}, parameter error too low')
+                pars_i, errs_i, cov_i, p_val = None, None, None, None
+
+            elif np.abs(n_events[0] -np.sum(hist))/np.sum(hist)>0.1:
+                print(f'hpge_fit_E_peaks: fit failed for i_peak={i_peak} at loc {mode_guesses[i_peak]:g}, n_events is outside limit')
+                pars_i, errs_i, cov_i, p_val = None, None, None, None
+                
+            elif p_val<0.1:
+                print(f'p-value too low: {p_val}')
+                pars_i, errs_i, cov_i, p_val = None, None, None, None
+
+        except: pars_i, errs_i, cov_i, p_val = None, None, None, None
+        
         #get binning
         binw_1 = (bins[-1]-bins[0])/(len(bins)-1)
 
         pars.append(pars_i)
+        errors.append(errs_i)
         covs.append(cov_i)
         binws.append(binw_1)
         ranges.append([Euc_min, Euc_max])
+        p_vals.append(p_val)
 
-    return np.array(pars, dtype = object), np.array(covs, dtype = object), np.array(binws), np.array(ranges)
+    return (np.array(pars, dtype = object), np.array(errors, dtype = object), 
+        np.array(covs, dtype = object), np.array(binws), np.array(ranges), np.array(p_vals))
 
 
 def hpge_fit_E_scale(mus, mu_vars, Es_keV, deg=0):
     """ Find best fit of poly(E) = mus +/- sqrt(mu_vars)
-
     Compare to hpge_fit_E_cal_func which fits for E = poly(mu)
-
     Parameters
     ----------
     mus : array
@@ -459,7 +438,6 @@ def hpge_fit_E_scale(mus, mu_vars, Es_keV, deg=0):
     deg : int
         degree for energy scale fit. deg=0 corresponds to a simple scaling
         mu = scale * E. Otherwise deg follows the definition in np.polyfit
-
     Returns
     -------
     pars : array
@@ -478,11 +456,9 @@ def hpge_fit_E_scale(mus, mu_vars, Es_keV, deg=0):
 
 def hpge_fit_E_cal_func(mus, mu_vars, Es_keV, E_scale_pars, deg=0):
     """ Find best fit of E = poly(mus +/- sqrt(mu_vars))
-
     This is an inversion of hpge_fit_E_scale.
     E uncertainties are computed from mu_vars / dmu/dE where mu = poly(E) is the
     E_scale function
-
     Parameters
     ----------
     mus : array
@@ -496,7 +472,6 @@ def hpge_fit_E_cal_func(mus, mu_vars, Es_keV, E_scale_pars, deg=0):
     deg : int
         degree for energy scale fit. deg=0 corresponds to a simple scaling
         mu = scale * E. Otherwise deg follows the definition in np.polyfit
-
     Returns
     -------
     pars : array
@@ -520,9 +495,10 @@ def hpge_fit_E_cal_func(mus, mu_vars, Es_keV, E_scale_pars, deg=0):
     return pars, cov
 
 
-def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False, range_keV=None, funcs=pgp.gauss_step, verbose=True):
+def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False, range_keV=None, 
+                        funcs=pgf.gauss_step_cdf, gof_funcs = None, method = 'unbinned', gof_func =None,
+                        simplex=False, verbose=True):
     """ Calibrate HPGe data to a set of known peaks
-
     Parameters
     ----------
     E_uncal : array
@@ -536,13 +512,16 @@ def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False,
         degree of the polynomial for the E_cal function E_keV = poly(E_uncal).
         deg = 0 corresponds to a simple scaling E_keV = scale * E_uncal.
         Otherwise follows the convention in np.polyfit
+    method: str 
+            default is unbinned fit can specify to use binned fit method instead
+    gof_funcs : function or array of functions
+            functions to use for calculation goodness of fit if unspecified will use same func as fit
     uncal_is_int : bool
         if True, attempts will be made to avoid picket-fencing when binning
         E_uncal
     range_keV : float, tuple, array of floats, or array of tuples of floats
         ranges around which the peak fitting is performed
         if tuple(s) are supplied, they provide the left and right ranges
-
     Returns
     -------
     pars, cov : array, 2D array
@@ -570,6 +549,12 @@ def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False,
     """
     results = {}
 
+    if not isinstance(range_keV, list):
+        range_keV = [range_keV for peak in peaks_keV]
+
+    if not hasattr(funcs, '__len__'):
+        funcs = [funcs for peak in peaks_keV]
+
     # sanity checks
     E_uncal = np.asarray(E_uncal)
     peaks_keV = np.asarray(peaks_keV) #peaks_keV = np.sort(peaks_keV)
@@ -659,37 +644,43 @@ def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False,
         range_uncal = [(r[0]/d, r[1]/d) if isinstance(r, tuple) else r/d for r, d in zip(range_keV, der)]
         n_bins = [sum(r)/0.5/d if isinstance(r, tuple) else r/0.2/d for r, d in zip(range_keV, der)]
 
-    pk_pars, pk_covs, pk_binws, pk_ranges = hpge_fit_E_peaks(E_uncal, got_peaks_locs, range_uncal, n_bins=n_bins,
-                                        funcs=funcs, uncal_is_int=uncal_is_int)
+    pk_pars,pk_errors, pk_covs, pk_binws, pk_ranges, pk_pvals = hpge_fit_E_peaks(E_uncal, got_peaks_locs, range_uncal, 
+                                    n_bins=n_bins,
+                                    funcs=funcs, method=method, gof_funcs = gof_funcs,
+                                    uncal_is_int=False, simplex=simplex)
     results['pk_pars'] = pk_pars
+    results['pk_errors'] = pk_errors
     results['pk_covs'] = pk_covs
     results['pk_binws'] = pk_binws
     results['pk_ranges'] = pk_ranges
-
+    results['pk_pvals'] = pk_pvals
     # Drop failed fits
     fitidx = [i is not None for i in pk_pars]
     fitted_peaks_keV = results['fitted_keV'] = got_peaks_keV[fitidx]
     funcs = [f for i, f in zip(fitidx, funcs) if i]
     pk_pars = results['pk_pars'] = np.asarray(pk_pars, dtype=object)[fitidx] #ragged
+    pk_errors = results['pk_errors'] = np.asarray(pk_errors, dtype=object)[fitidx]
     pk_covs = results['pk_covs'] = np.asarray(pk_covs, dtype=object)[fitidx]
     pk_binws = results['pk_binws'] = np.asarray(pk_binws)[fitidx]
     pk_ranges = results['pk_ranges'] = np.asarray(pk_ranges)[fitidx]
+    pk_pvals = results['pk_pvals'] = np.asarray(pk_pvals)[fitidx]
     if verbose:
         print(f"{sum(fitidx)} peaks fitted:")
-        for i, (Ei, parsi, covsi) in enumerate(zip(fitted_peaks_keV, pk_pars, pk_covs)):
+        for i, (Ei, parsi, errorsi, covsi) in enumerate(zip(fitted_peaks_keV, pk_pars, pk_errors, pk_covs)):
             func_i = funcs[i] if hasattr(funcs, '__len__') else funcs
             varnames = func_i.__code__.co_varnames[1:len(pk_pars[-1])+1]
             parsi = np.asarray(parsi, dtype=float)
+            errorsi = np.asarray(errorsi, dtype=float)
             covsi = np.asarray(covsi, dtype=float)
-            parsigsi = np.sqrt(covsi.diagonal())
+            #parsigsi = np.sqrt(covsi.diagonal())
             print(f'\tEnergy: {str(Ei)}')
             print(f'\t\tParameter  |    Value +/- Sigma  ')
-            for (vari, pari, parsigi) in zip(varnames, parsi, parsigsi):
-                print(f'\t\t{str(vari).ljust(10)} | {("%4.2f" % pari).rjust(8)} +/- {("%4.2f" % parsigi).ljust(8)}')
+            for (vari, pari, errorsi) in zip(varnames, parsi, errorsi):
+                print(f'\t\t{str(vari).ljust(10)} | {("%4.2f" % pari).rjust(8)} +/- {("%4.2f" % errorsi).ljust(8)}')
                 #fwhm??
 
     # Do a second calibration to the results of the full peak fits
-    mus = [pgf.get_mu_func(func_i, pars_i, covs_i) for func_i, pars_i, covs_i in zip(funcs, pk_pars, pk_covs)]
+    mus = [pgf.get_mu_func(func_i, pars_i, errors=errors_i) for func_i, pars_i, errors_i in zip(funcs, pk_pars, pk_errors)]
     mus, mu_vars = zip(*mus)
     mus = np.asarray(mus)
     mu_vars = np.asarray(mu_vars)**2
@@ -702,29 +693,35 @@ def hpge_E_calibration(E_uncal, peaks_keV, guess_keV, deg=0, uncal_is_int=False,
     pars, cov = hpge_fit_E_cal_func(mus, mu_vars, fitted_peaks_keV, pars, deg=deg)
 
     # Finally, calculate fwhms in keV
-    uncal_fwhms = [pgf.get_fwhm_func(func_i, pars_i, covs_i) for func_i, pars_i, covs_i in zip(funcs, pk_pars, pk_covs)]
+    uncal_fwhms = [pgf.get_fwhm_func(func_i, pars_i, cov = covs_i) for func_i, pars_i, covs_i in zip(funcs, pk_pars, pk_covs)]
     uncal_fwhms, uncal_fwhm_errs = zip(*uncal_fwhms)
     uncal_fwhms = np.asarray(uncal_fwhms)
     uncal_fwhm_errs = np.asarray(uncal_fwhm_errs)
     derco = np.polyder(np.poly1d(pars)).coefficients
     der = [pgf.poly(Ei, derco) for Ei in fitted_peaks_keV]
+
+    cal_fwhms = uncal_fwhms * der 
+    cal_fwhms_errs = uncal_fwhm_errs*der
     results['pk_fwhms'] = np.asarray([(u*d, e*d) for u, e, d in zip(uncal_fwhms, uncal_fwhm_errs, der)])
 
-    return pars, cov, results
+    if verbose:
+        print(f"{len(cal_fwhms)} FWHMs found:")
+        print(f'\t   Energy   | FWHM  ')
+        for i, (Ei, fwhm, fwhme) in enumerate(zip(fitted_peaks_keV, cal_fwhms, cal_fwhms_errs)):
+            print(f'\t{i}'.ljust(4) + str(Ei).ljust(9) + f'| {fwhm:.2f}+-{fwhme:.2f} keV'.ljust(5))
+
+    return   pars, cov, results  
 
 
 
 def poly_match(xx, yy, deg=-1, rtol=1e-5, atol=1e-8):
     """
     Find the polynomial function best matching pol(xx) = yy
-
     Finds the poly fit of xx to yy that obtains the most matches between pol(xx)
     and yy in the np.isclose() sense. If multiple fits give the same number of
     matches, the fit with the best gof is used, where gof is computed only among
     the matches.
-
     Assumes that the relationship between xx and yy is monotonic
-
     Parameters
     ----------
     xx : array-like
@@ -743,7 +740,6 @@ def poly_match(xx, yy, deg=-1, rtol=1e-5, atol=1e-8):
     atol : float
         the absolute tolerance to be sent to np.isclose(). Has the same units
         as yy.
-
     Returns
     -------
     pars: None or array of floats
@@ -867,12 +863,9 @@ def poly_match(xx, yy, deg=-1, rtol=1e-5, atol=1e-8):
 def get_i_local_extrema(data, delta):
     """
     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 delta.
-
     Converted from MATLAB script at: http://billauer.co.il/peakdet.html
-
     Parameters
     ----------
     data : array-like
@@ -880,7 +873,6 @@ def get_i_local_extrema(data, delta):
     delta : scalar
         the absolute level by which data must vary (in one direction) about an
         extremum in order for it to be tagged
-
     Returns
     -------
     imaxes, imins : 2-tuple ( array, array )
@@ -1019,7 +1011,6 @@ def calibrate_tl208(energy_series, cal_peaks=None, plotFigure=None):
     """
     energy_series: array of energies we want to calibrate
     cal_peaks: array of peaks to fit
-
     1.) we find the 2614 peak by looking for the tallest peak at >0.1 the max adc value
     2.) fit that peak to get a rough guess at a calibration to find other peaks with
     3.) fit each peak in peak_energies
diff --git a/pygama/processor_list.json b/pygama/processor_list.json
deleted file mode 100644
index 603e9c7bd..000000000
--- a/pygama/processor_list.json
+++ /dev/null
@@ -1,80 +0,0 @@
-
-{
-  "outputs": [
-   "max_index", "bl","bl_sig","slope","intercept", "tail_slope", "pz_slope","pz_std","trapEmax"
-  ],
-  "processors":{
-
-
-    "max_index":{
-      "function": "argmax",
-      "module": "numpy",
-      "args": ["waveform", 1, "max_index"],
-      "kwargs": {"signature":"(n),()->()", "types":["fi->i"]},
-      "unit": "ADC"
-    },
-
-
-    "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"]
-    },
-
-     "wf_blsub":{
-       "function": "subtract",
-       "module": "numpy",
-       "args": ["waveform", "bl", "wf_blsub"],
-       "unit": "ADC"
-    },
-
-
-      "wf_logged":{
-          "function": "log_check",
-          "module": "pygama.dsp.processors",
-          "args": ["wf_blsub[2100:]", "wf_logged"],
-          "unit": "ADC"
-       },
-
-      "tail_mean , tail_std, tail_slope, tail_intercept":{
-         "function": "linear_slope_fit",
-         "module": "pygama.dsp.processors",
-         "args" : ["wf_logged", "tail_mean","tail_std", "tail_slope","tail_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":"44.56*us" }
-      },
-
-      "pz_mean , pz_std, pz_slope, pz_intercept":{
-        "function": "linear_slope_fit",
-        "module": "pygama.dsp.processors",
-        "args" : ["wf_pz[2100:]", "pz_mean","pz_std", "pz_slope","pz_intercept"],
-        "unit": ["ADC","ADC","ADC","ADC"]
-      },
-      "wf_trap": {
-        "function": "trap_norm",
-        "module": "pygama.dsp.processors",
-        "args": ["wf_pz", "8*us", "2*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/pygama/raw/build_raw.py b/pygama/raw/build_raw.py
index 3ba17f078..64dda326a 100644
--- a/pygama/raw/build_raw.py
+++ b/pygama/raw/build_raw.py
@@ -11,8 +11,8 @@
 #from stream_fc import *
 
 
-def build_raw_files(in_stream, in_stream_type, out_spec=None, buffer_size=8192, 
-                    n_max=np.inf, overwrite=True, verbosity=0)
+def build_raw(in_stream, in_stream_type, out_spec=None, buffer_size=8192, 
+              n_max=np.inf, overwrite=True, verbosity=0)
     """ Convert data into LEGEND hdf5 `raw` format.  
 
     Takes an input stream (in_stream) of a given type (in_stream_type) and
@@ -109,10 +109,8 @@ def build_raw_files(in_stream, in_stream_type, out_spec=None, buffer_size=8192,
         return
 
     # initialize the stream and read header. Also initializes rb_lib
-    header_data, n_bytes = streamer.initialize(in_stream, in_stream_type, rb_lib, 
-                                               buffer_size=buffer_size, verbosity=verbosity)
-    bytes_processed += n_bytes
-    if verbosity > 0: update_progress(float(n_bytes)/in_stream_size)
+    header_data = streamer.open_stream(in_stream, rb_lib=rb_lib, buffer_size=buffer_size, verbosity=verbosity)
+    if verbosity > 0: update_progress(float(streamer.n_bytes_read)/in_stream_size)
 
     # rb_lib should now be fully initialized. Check if files need to be
     # overwritten or if we need to stop to avoid overwriting
@@ -134,9 +132,8 @@ def build_raw_files(in_stream, in_stream_type, out_spec=None, buffer_size=8192,
 
     # Now loop through the data
     while True:
-        chunk_list, n_bytes = streamer.read_chunk(full_only=True, verbosity=verbosity)
-        bytes_processed += n_bytes
-        if verbosity > 0: update_progress(float(n_bytes)/in_stream_size)
+        chunk_list = streamer.read_chunk(full_only=True, verbosity=verbosity)
+        if verbosity > 0: update_progress(float(streamer.n_bytes_read)/in_stream_size)
         for rb in chunk_list:
             if rb.loc > n_max: rb.loc = n_max
             n_max -= rb.loc
@@ -144,7 +141,7 @@ def build_raw_files(in_stream, in_stream_type, out_spec=None, buffer_size=8192,
         if len(chunk_list) == 0 or n_max == 0: break
     if verbosity > 0: update_progress(1)
 
-    # Write out all buffers with any data
+    # Write out any buffers with data still in them
     all_rbs = []
     for rb_list in rb_lib.values(): all_rbs += rb_list
     write_to_lh5_and_clear(chunk_list, lh5_store)
diff --git a/pygama/raw/data_decoder.py b/pygama/raw/data_decoder.py
index ff511e967..888f35fed 100644
--- a/pygama/raw/data_decoder.py
+++ b/pygama/raw/data_decoder.py
@@ -45,7 +45,11 @@ def __init__(self, garbage_length=256, packet_size_guess=1024):
                                      lgdo.Array(shape=garbage_length, dtype='uint32'))
 
 
-    def get_keys_list(self): pass
+    def get_keys_list(self): 
+        """ overload with list of keys for this decoder.
+        e.g. `return range(n_channels)`
+        """
+        pass
 
 
     def get_decoded_values(self, key=None):
@@ -64,6 +68,17 @@ def get_decoded_values(self, key=None):
         return None
 
 
+    def setup_raw_buffers(self, rb_list, size=None):
+        """ Setup raw buffers for this decoder
+
+        Uses the first key in a RawBuffer's key_list for initialization
+        """
+        for rb in rb_list:
+            if rb.lgdo is not None: print("warning: replacing non-None lgdo")
+            key = None if len(rb.key_list) == 0 else rb.key_list[0]
+            rb.lgdo = self.make_lgdo(key=key, size=size)
+
+
     def make_lgdo(self, key=None, size=None):
         """ Make an lgdo for this DataDecoder
 
diff --git a/pygama/raw/data_streamer.py b/pygama/raw/data_streamer.py
index 23a0b39e6..a5b1420a9 100644
--- a/pygama/raw/data_streamer.py
+++ b/pygama/raw/data_streamer.py
@@ -2,40 +2,78 @@
 from raw_buffer import RawBufferLibrary
 
 class DataStreamer(ABC):
-    """ Base clase for data streams """
+    """ Base clase for data streams 
 
-    def __init__(self)
+    Provides a uniform interface for streaming, e.g.
+
+    > header = ds.open_stream(stream_name)
+    > for chunk in ds: do_something(chunk)
+
+    Also provides default management of the RawBufferLibrary used for data reading:
+    - allocation (if needed)
+    - configuration (to match the stream)
+    - fill level checking
+
+    Derived classes must define the functions get_decoder_list(), open_stream(),
+    and read_packet(); see below.
+    """
+
+    def __init__(self):
         self.rb_lib = None
+        self.chunk_mode = None
+        self.n_bytes_read = 0
+        self.any_full = False
 
-    def initialize(self, in_stream, rb_lib, buffer_size=8192, verbosity=0):
-        """ Initialize a data stream
 
-        Open the in_stream, read in the header, set up the buffers
+    def open_stream(self, stream_name, rb_lib=None, buffer_size=8192,
+                    chunk_mode='any_full', out_stream='', verbosity=0):
+        """ Open and initialize a data stream
+
+        Open the stream, read in the header, set up the buffers
+
+        Call super().initialize([args]) from derived class after loading header
+        info to run this default version that sets up buffers in rb_lib using
+        the stream's decoders
+
+        Note: this default version has no actual return value! You must overload
+        this function, set self.n_bytes_read to the header packet size, and
+        return the header data
 
         Parameters
         ----------
-        in_stream : str
+        stream_name : str
             typically a filename or e.g. a port for streaming
         rb_lib : RawBufferLibrary
-            A library of buffers for readout from the data stream. rb_lib should
-            be initialized during this function
+            A library of buffers for readout from the data stream. rb_lib will
+            have its lgdo's initialized during this function
         buffer_size : int
-            length of buffers to be read out in read_chunk
+            length of buffers to be read out in read_chunk (for buffers with
+            variable length)
+        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
         verbosity : int
             verbosity level for the initialize function
 
         Returns
         -------
-        header_data, n_bytes: list(RawBuffer), int
+        header_data: list(RawBuffer), int
             header_data is a list of RawBuffer's containing any file header
                 data, ready for writing to file or further processing. It's not
                 a RawBufferList since the buffers may have a different format
-            n_bytes is the number of bytes read from the file to extract the
-                header data
         """
-        # call super().initialize(*args, **kwargs) to run this default code
+        # call super().initialize([args]) to run this default code
         # after loading header info, then follow it with the return call.
+
+        # store chunk mode
+        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)
         self.rb_lib = rb_lib
+
+        # now initialize lgdo's for raw buffers
         decoders = self.get_decoder_list()
         dec_names = []
         for decoder in decoders:
@@ -64,22 +102,52 @@ def initialize(self, in_stream, rb_lib, buffer_size=8192, verbosity=0):
 
 
 
-    def read_chunk(self, full_only=True, verbosity=0):
+    def read_packet(self, verbosity=0):
+        """
+        Reads a single packet's worth of data in to the rb_lib
+
+        Needs to be overloaded. Gets called by read_chunk()
+
+        Needs to update self.any_full if any buffers would possibly over-fill on
+        the next read
+
+        Needs to update self.n_bytes_read too
+
+        Returns
+        -------
+        still_has_data : bool
+            Returns true while there is still data to read
+        """
+        return true
+
+
+    def read_chunk(self, chunk_mode_override=None, rp_max=1000000, verbosity=0):
         """
         Reads a chunk of data into raw buffers
 
+        Reads packets until at least one buffer is too full to perform another
+        read.
+
         Note: user is responsible for resetting / clearing the raw buffers prior
         to calling read_chunk again.
 
+        Default version just calls read_packet() over and over. Overload as
+        necessary.
+
         Parameters
         ----------
-        full_only : bool
-            If true, the returned chunk_list contains only those raw buffers
-            that became full (or nearly full) during the read. This minimizes
-            the number of write calls. 
-            If false, returns all raw buffers with data. This is useful for
-            streaming data out as soon as it is read in (e.g. for in-line
-            analysis)
+        chunk_mode_override: 'any_full', 'only_full', 'single_packet', or None
+            None : do not override self.chunk_mode
+            'any_full' : returns all raw buffers with data as soon as any one
+                buffer gets full
+            'only_full' : returns only those raw buffers that became full (or
+                nearly full) during the read. This minimizes the number of write calls.
+            'single_packet' : returns all raw buffers with data after a single
+                read is performed. This is useful for streaming data out as soon
+                as it is read in (e.g. for diagnostics or in-line analysis)
+        rp_max : int
+            maximum number of packets to read before returning anyway, even if
+            one of the other conditions is not met
         verbosity : int
             verbosity level for the initialize function
 
@@ -94,15 +162,38 @@ def read_chunk(self, full_only=True, verbosity=0):
             n_bytes is the number of bytes read from the file during this
                 iteration.
         """
+
+        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')
+
+        n_packets = 0
+        while True:
+            still_has_data = self.read_packet()
+            if (not still_has_data) break
+            n_packets += 1
+            if read_one_packet or n_packets > rp_max: break
+            if self.any_full break
+
+        list_of_rbs = []
+        for rb_list in self.rb_lib.items():
+            for rb in rb_list:
+                if not only_full and rb.loc > 0: list_of_rbs.append(rb)
+                else:
+                    if not hasattr(rb.lgod, __len__):
+                        if rb.loc > 0: list_of_rbs.append(rb)
+                    else:
+                        if rb.loc == len(rb.lgdo): list_of_rbs.append(rb)
+
+        return list_of_rbs
+
         
-        pass
 
 
     def get_decoder_list(self):
         """Returns a list of decoder objects for this data stream.
-
-        Needs to be overloaded. Should be called after the stream is opened /
-        initialized.
+        Needs to be overloaded. Gets called during open_stream().
         """
         return []
 
@@ -120,7 +211,8 @@ def build_default_rb_lib(self, out_stream=''):
             print(f'No decoders returned by get_decoder_list() for {type(self).__name__}')
             return rb_lib
         for decoder in decoders:
-            rb = RawBuffer(out_stream=out_stream, out_name=decoder)
-            rb_lib[decoder] = RawBufferList()
-            rb_lib[decoder].append(rb)
+            dec_name = type(decoder).__name__
+            rb = RawBuffer(out_stream=out_stream, out_name=dec_name)
+            rb_lib[dec_name] = RawBufferList()
+            rb_lib[dec_name].append(rb)
 
diff --git a/pygama/raw/fc/fc_event_decoder.py b/pygama/raw/fc/fc_event_decoder.py
index aaabdde0e..41f83b19d 100644
--- a/pygama/raw/fc/fc_event_decoder.py
+++ b/pygama/raw/fc/fc_event_decoder.py
@@ -160,7 +160,7 @@ def decode_packet(self, fcio, lgdo_tables, packet_id, verbosity=0):
             The interface to the fcio data. Enters this function after a call to
             fcio.get_record() so that data for packet_id ready to be read out
         lgdo_tables : lgdo.Table or dict
-            A single table for readign out all data, or a dict of tables keyed
+            A single table for reading out all data, or a dict of tables keyed
             by channel number (i.e. as returned by raw_groups.build_tables())
         packet_id : int
             The index of the packet in the fcio stream. Incremented by
diff --git a/pygama/raw/fc/fc_streamer.py b/pygama/raw/fc/fc_streamer.py
index 6d2aee370..ac6f6e96b 100644
--- a/pygama/raw/fc/fc_streamer.py
+++ b/pygama/raw/fc/fc_streamer.py
@@ -15,23 +15,28 @@ class FCStreamer(DataStreamer):
     """
     def __init__(self):
         super().__init__(self)
+        self.fcio = None
         self.config_decoder = FCConfigDecoder()
         self.status_decoder = FCStatusDecoder()
         self.event_decoder = FCEventDecoder()
         self.event_tables = {}
 
+
+
     def get_decoder_list(self):
         dec_list = []
         dec_list.append(self.config_decoder)
         dec_list.append(self.status_decoder)
         dec_list.append(self.event_decoder)
 
-    def initialize(self, fcio_file, rb_lib, buffer_size=8192, verbosity=0):
+
+
+    def open_stream(self, fcio_filename, rb_lib=None, buffer_size=8192, verbosity=0):
         """ Initialize the FC data stream
 
         Parameters
         ----------
-        fcio_file : str
+        fcio_filename : str
             the FCIO filename 
         rb_lib : RawBufferLibrary
             library of buffers for this stream
@@ -42,87 +47,91 @@ def initialize(self, fcio_file, rb_lib, buffer_size=8192, verbosity=0):
  
         Returns
         -------
-        header_data, n_bytes : list(RawBuffer), int
-            header_data is a list of length 1 containing the raw buffer holding
-                the fc_config table
-            n_bytes is the number of bytes read from the file to extract the
-                header data
+        header_data : list(RawBuffer)
+            a list of length 1 containing the raw buffer holding the fc_config table
         """
-        self.fcio = fcutils.fcio(fcio_file)
+        self.fcio = fcutils.fcio(fcio_filename)
+        self.n_bytes_read = 0
 
         # read in file header (config) info
-        fc_config = self.config_decoder.decode_config(fcio)
+        fc_config = self.config_decoder.decode_config(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
 
-        super().initialize(fcio_file, rb_lib, buffer_size=buffer_size, verbosity=verbosity)
-
-        # build raw_groups and set up tables
-        self.raw_groups = None
-        if (raw_groups_library is not None) and ('FCEventDecoder' in raw_groups_dict):
-            # get raw_groups
-            self.raw_groups = raw_groups_dict['FCEventDecoder']
-            expand_raw_groups(self.raw_groups)
-        else:
-            if verbosity > 0: print('Config not found.  Single-table mode')
-            self.raw_groups = create_dummy_raw_groups()
-        self.event_tables = build_tables(self.raw_groups, buffer_size, init_obj=event_decoder)
-
-        self.status_tbl = lgdo.Table(buffer_size)
-        self.status_decoder.initialize_lgdo_table(status_tbl)
+        # initialize the buffers in rb_lib
+        super().initialize(fcio_filename, rb_lib, buffer_size=buffer_size, verbosity=verbosity)
+        if rb_lib = None: rb_lib = self.rb_lib
 
         # set up data loop variables
         self.packet_id = 0
         self.max_numtraces = 0
-        return [fc_config]
 
+        if 'FCConfigDecoder' in rb_lib: rb = rb_lib['FCConfigDecoder']
+        else: rb = RawBuffer(lgdo=fc_config)
+        rb.loc = 1 # we have filled this buffer
+        return [rb]
 
 
-    def read_chunk(self, verbosity=0):
-        """
-        Returns
-        -------
-        chunk : list of lgdos
-            the returned list of lgdos (tables, etc) is ready for writing to
-            file or further processing
+
+    def read_packet(self, verbosity=0):
+        """ Read a packet of data.
+
+        Data written to self.rb_lib.
+        Updates self.n_bytes_read
         """
-        rc = 1
-        n_bytes = 0
-        while rc:
-            rc = self.fcio.get_record()
-
-            # Skip non-interesting records
-            # FIXME: push to a buffer of skipped packets?
-            if rc == 0 or rc == 1 or rc == 2 or rc == 5: continue
-
-            packet_id += 1
-
-            # Status record
-            if rc == 4:
-                n_bytes += self.status_decoder.decode_packet(self.fcio, self.status_tbl, self.packet_id)
-                if self.status_tbl.is_full(): return [ self.status_tbl ], n_bytes
-
-            # Event or SparseEvent record
-            if rc == 3 or rc == 6:
-
-                # check that tables are large enough to read in this packet. If
-                # not, exit and return the ones that might overflow
-                full_tables = []
-                for group_info in self.raw_groups.values():
-                    tbl = group_info['table']
-                    # Check that the tables are large enough
-                    # TODO: don't need to check this every event, only if sum(numtraces) >= buffer_size
-                    if tbl.size < self.fcio.numtraces and self.fcio.numtraces > self.max_numtraces:
-                        print('warning: tbl.size =', tbl.size,
-                              'but fcio.numtraces =', self.fcio.numtraces)
-                        print('may overflow. suggest increasing tbl.size')
-                        self.max_numtraces = fcio.numtraces
-                    # Return if tables are too full.
-                    if tbl.size - tbl.loc < fcio.numtraces: full_tables.append(tbl)
-                if len(full_tables) > 0: return full_tables, n_bytes
-
-                # Looks okay: just decode
-                n_bytes += self.event_decoder.decode_packet(self.fcio, self.event_tables, self.packet_id)
 
+        rc = self.fcio.get_record()
+        if rc == 0: return False # no more data
+
+        self.packet_id += 1
+
+        elif rc == 1: # config (header) data
+            print(f'warning: got a header after start of run?')
+            print(f'         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
+            print(f'warning: got a calib record?')
+            print(f'         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
+            print(f'warning: got a RecEvent packet -- skipping?')
+            print(f'         n_bytes_read = {self.n_bytes_read}')
+            self.n_bytes_read += 6*4 + 3*10*4 + 1*2304*4 + 3*4000*4 # there are 11 ints in the fcio_config struct
+            return True
+
+        # Status record
+        if rc == 4:
+            n_bytes += self.status_decoder.decode_packet(self.fcio, self.status_tbl, self.packet_id)
+            if self.status_tbl.is_full(): return [ self.status_tbl ], n_bytes
+
+        # Event or SparseEvent record
+        if rc == 3 or rc == 6:
+
+            # check that tables are large enough to read in this packet. If
+            # not, exit and return the ones that might overflow
+            full_tables = []
+            for group_info in self.raw_groups.values():
+                tbl = group_info['table']
+                # Check that the tables are large enough
+                # TODO: don't need to check this every event, only if sum(numtraces) >= buffer_size
+                if tbl.size < self.fcio.numtraces and self.fcio.numtraces > self.max_numtraces:
+                    print('warning: tbl.size =', tbl.size,
+                          'but fcio.numtraces =', self.fcio.numtraces)
+                    print('may overflow. suggest increasing tbl.size')
+                    self.max_numtraces = fcio.numtraces
+                # Return if tables are too full.
+                if tbl.size - tbl.loc < fcio.numtraces: full_tables.append(tbl)
+            if len(full_tables) > 0: return full_tables, n_bytes
+
+            # Looks okay: just decode
+            n_bytes += self.event_decoder.decode_packet(self.fcio, self.event_tables, self.packet_id)
+
+'''
     # finished with loop. return any tables with data
     tables = []
     for group_info in self.raw_groups.values():
@@ -287,3 +296,5 @@ def process_flashcam(daq_file, raw_files, n_max, raw_groups_dict=None, verbose=F
                 print("  ch", ch, ":", n, "hits")
 
     return bytes_processed
+'''
+
diff --git a/pygama/raw/orca/orca_digitizers.py b/pygama/raw/orca/orca_digitizers.py
index 43945674e..a15888138 100644
--- a/pygama/raw/orca/orca_digitizers.py
+++ b/pygama/raw/orca/orca_digitizers.py
@@ -2,6 +2,7 @@
 import numpy as np
 
 from .orcadaq import OrcaDecoder, get_ccc
+from pygama.lh5 import Table
 
 class ORCAStruck3302(OrcaDecoder):
     """
@@ -12,7 +13,7 @@ def __init__(self, *args, **kwargs):
         self.decoder_name = 'ORSIS3302DecoderForEnergy'
         self.orca_class_name = 'ORSIS3302Model'
 
-        self.decoded_values = {
+        self.decoded_values_template = {
             'packet_id': {
                'dtype': 'uint32',
              },
@@ -50,14 +51,23 @@ def __init__(self, *args, **kwargs):
         }
 
         super().__init__(*args, **kwargs) # also initializes the garbage df
-        # self.enabled_cccs = []
-        self.skipped_channels = {}
+
+        self.decoded_values = {}
         self.ievt = 0
+        self.skipped_channels = {}
+        # self.enabled_cccs = []
 
 
     def get_decoded_values(self, channel=None):
-        # TODO: return channel-specific decoded_values
-        return self.decoded_values
+        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):
@@ -71,27 +81,26 @@ def set_object_info(self, object_info):
             int_enabled_mask = card_dict['internalTriggerEnabledMask']
             ext_enabled_mask = card_dict['externalTriggerEnabledMask']
             enabled_mask = int_enabled_mask | ext_enabled_mask
-            trace_length = 0
-            for channel in range(8):
+            for channel in range(10):
                 # only care about enabled channels
-                if (enabled_mask >> channel) & 0x1:
-                    # save list of enabled channels
-                    #self.enabled_cccs.append(get_ccc(crate, card, channel))
+                if not ((enabled_mask >> channel) & 0x1): continue
 
-                    # get trace length(s). Should all be the same until
-                    # multi-buffer mode is implemented AND each channel has its
-                    # own buffer
-                    this_length = card_dict['sampleLengths'][int(channel/2)]
-                    if trace_length == 0: trace_length = this_length
-                    elif this_length != trace_length:
-                        print('SIS3316ORCADecoder Error: multiple trace lengths not supported')
-                        sys.exit()
+                ccc = get_ccc(crate, card, channel)
+                # save list of enabled channels
+                #self.enabled_cccs.append(ccc)
 
-            # check trace length and update decoded_values
-            if trace_length <= 0 or trace_length > 2**16:
-                print('SIS3316ORCADecoder Error: invalid trace_length', trace_length)
-                sys.exit()
-            self.decoded_values['waveform']['length'] = trace_length
+                self.decoded_values[ccc] = {}
+                self.decoded_values[ccc].update(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):
@@ -106,20 +115,20 @@ def decode_packet(self, packet, lh5_tables, packet_id, header_dict, verbose=Fals
         # 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 
+        # 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: 
+                if ccc not in self.skipped_channels:
                     self.skipped_channels[ccc] = 0
                 self.skipped_channels[ccc] += 1
                 return
@@ -270,7 +279,7 @@ def __init__(self, *args, **kwargs):
 
 
     def get_decoded_values(self, channel=None):
-        if channel is 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!")
@@ -278,6 +287,7 @@ def get_decoded_values(self, channel=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
 
 
@@ -299,7 +309,7 @@ def set_object_info(self, object_info):
             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(8):
+            for channel in range(10):
                 # only care about enabled channels
                 if not is_enabled[channel]: continue
                 ccc = get_ccc(crate, card, channel)
@@ -347,9 +357,9 @@ def decode_packet(self, packet, lh5_tables, packet_id, header_dict, verbose=Fals
 
         # aliases for brevity
         tb = lh5_tables
-        if isinstance(tb, dict): 
+        if not isinstance(tb, Table):
             if ccc not in lh5_tables:
-                if ccc not in self.skipped_channels: 
+                if ccc not in self.skipped_channels:
                     self.skipped_channels[ccc] = 0
                 self.skipped_channels[ccc] += 1
                 return
@@ -433,10 +443,10 @@ def decode_packet(self, packet, lh5_tables, packet_id, header_dict, verbose=Fals
         self.ievt += 1
 
 
-'''
-class SIS3316ORCADecoder(DataTaker):
+class SIS3316ORCADecoder(OrcaDecoder):
     """
-    handle ORCA Struck 3316 digitizer
+    Decode ORCA Struck 3316 digitizer data.
+    Thanks to J. Browning of COHERENT for getting this updated!
 
     TODO:
     handle per-channel data (gain, ...)
@@ -445,62 +455,165 @@ class SIS3316ORCADecoder(DataTaker):
     def __init__(self, *args, **kwargs):
 
         self.decoder_name = 'ORSIS3316WaveformDecoder'
-        self.class_name = 'ORSIS3316Model'
+        self.orca_class_name = 'ORSIS3316Model'
 
         # store an entry for every event
         self.decoded_values = {
-            "packet_id": [],
-            "ievt": [],
-            "energy_first": [],
-            "energy": [],
-            "timestamp": [],
-            "channel": [],
-            "waveform": [],
+            '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 initalizing 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.sample_period = 10  # ns, I will set this later, according to header info
-        self.gain = 0
-        self.h5_format = "table"
-        self.ievt = 0       #event number
-        self.ievt_gbg = 0      #garbage event number
-        self.window = False
+        self.skipped_channels = {}
+        self.ievt = 0
+
+
+    def get_decoded_values(self, channel=None):
+        # TODO: return channel-specific decoded_values
+        return self.decoded_values
+
+
+    def set_object_info(self, object_info):
+        self.object_info = object_info
+        trace_length = 65000
+
+        # parse object_info for important info
+        for card_dict in self.object_info:
+            crate = card_dict['Crate']
+            card = card_dict['Card']
+
+            try:
+                int_enabled_mask = card_dict['internalTriggerEnabledMask']
+                ext_enabled_mask = card_dict['externalTriggerEnabledMask']
+                enabled_mask = int_enabled_mask | ext_enabled_mask
+                trace_length = 0
+                for channel in range(8):
+                    # only care about enabled channels
+                    if (enabled_mask >> channel) & 0x1:
+                    # save list of enabled channels
+                    #self.enabled_cccs.append(get_ccc(crate, card, channel))
+
+                    # get trace length(s). Should all be the same until
+                    # multi-buffer mode is implemented AND each channel has its
+                    # own buffer
+                        this_length = card_dict['sampleLengths'][int(channel/2)]
+                        if trace_length == 0: trace_length = this_length
+                        elif this_length != trace_length:
+                            print('SIS3316ORCADecoder Error: multiple trace lengths not supported')
+                            sys.exit()
+            except:
+                print('I dont know what this is for')
+
+            # check trace length and update decoded_values
+            if trace_length <= 0 or trace_length > 2**16:
+                print('SIS3316ORCADecoder Error: invalid trace_length', trace_length)
+                sys.exit()
+            self.decoded_values['waveform']['length'] = trace_length
+
 
+    def max_n_rows_per_packet(self):
+        return 1
 
-    def decode_event(self, event_data_bytes, packet_id, header_dict, verbose=False):
+
+    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(event_data_bytes, dtype=np.uint32)
-        evt_data_16 = np.fromstring(event_data_bytes, dtype=np.uint16)
+        evt_data_32 = np.fromstring(packet, dtype=np.uint32)
+        evt_data_16 = np.fromstring(packet, dtype=np.uint16)
+
+        tb = lh5_tables
+
+        crate = evt_data_32[3]
+        card = evt_data_32[4]
+        try:
+            channel = (evt_data_32[9] & 0xFFF0) >> 4
+        except:
+            print('Something went wrong')
+            channel = 33
+        ccc = get_ccc(crate, card, 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
+
+        tb['packet_id'].nda[ii] = packet_id
 
-        #TODO Figure out the header, particularly card/crate/channel/timestamp
+        # TODO: Figure out the header, particularly card/crate/channel/timestamp
         n_lost_msb = 0
         n_lost_lsb = 0
         n_lost_records = 0
-        crate = evt_data_32[3]
-        card = evt_data_32[4]
-        channel = evt_data_32[4]
+        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
-        timestamp = 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 = 1024
+        wf_length16 = 65000
         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)/2
+        expected_wf_length = (len(evt_data_16) - header_length16 - ene_wf_length16)
 
-        if wf_length16 != expected_wf_length:
-            print("ERROR: Waveform size %d doesn't match expected size %d." %
-                  (wf_length16, expected_wf_length))
-            #exit()
+        # 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
@@ -508,25 +621,25 @@ def decode_event(self, event_data_bytes, packet_id, header_dict, verbose=False):
         i_ene_start = i_wf_stop + 1
         i_ene_stop = i_ene_start + ene_wf_length16
 
-
+        tbwf = tb['waveform']['values'].nda[ii]
+        #print(len(tb['waveform']['values'].nda[ii]))
         # handle the waveform(s)
         if wf_length16 > 0:
-            wf_data = evt_data_16[i_wf_start:i_wf_stop]
-
-
-        #TODO check if number of events matches expected
-        #if len(wf_data) != expected_wf_length:
+            try:
+                tb['waveform']['values'].nda[ii] = evt_data_16[i_wf_start:i_wf_stop]
+            except:
+                print(len(evt_data_16[i_wf_start:i_wf_stop]))
+                print(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
-        waveform = wf_data
 
         # set the event number (searchable HDF5 column)
-        ievt = self.ievt
+        tb['ievt'].nda[ii] = self.ievt
         self.ievt += 1
-
-        # send any variable with a name in "decoded_values" to the pandas output
-        self.format_data(locals())
-'''
+        tb.push_row()
diff --git a/pygama/raw/orca/stream_orca.py b/pygama/raw/orca/stream_orca.py
index 0498de260..ca2ea47b4 100644
--- a/pygama/raw/orca/stream_orca.py
+++ b/pygama/raw/orca/stream_orca.py
@@ -1,7 +1,9 @@
 import sys, gzip
 import numpy as np
 import plistlib
-from ..utils import update_progress
+
+from tqdm.std import tqdm
+from ..utils import tqdm_range, update_progress
 from .io_base import DataDecoder
 from pygama import lh5
 from .ch_group import *
@@ -34,7 +36,7 @@ def set_object_info(self, object_info):
 
 
 def open_orca(orca_filename):
-    if orca_filename.endswith('.gz'): 
+    if orca_filename.endswith('.gz'):
         return gzip.open(orca_filename.encode('utf-8'), 'rb')
     else: return open(orca_filename.encode('utf-8'), 'rb')
 
@@ -171,7 +173,54 @@ def get_object_info(header_dict, orca_class_name):
                 object_info_list.append(card)
 
     if len(object_info_list) == 0:
-        print('OrcaDecoder::get_object_info(): Warning: no object info')
+        print('OrcaDecoder::get_object_info(): Warning: no object info '
+              'for class name', orca_class_name)
+    return object_info_list
+
+
+def get_readout_info(header_dict, orca_class_name, unique_id=-1):
+    """
+    retunrs a list with all the readout list info from the header with name
+    orca_class_name.  optionally, if unique_id >= 0 only return the list for
+    that Orca unique id number.
+    """
+    object_info_list = []
+    try:
+        readouts = header_dict["ReadoutDescription"]
+        for readout in readouts:
+            try:
+                if readout["name"] == orca_class_name:
+                    if unique_id >= 0:
+                        if obj["uniqueID"] != unique_id: continue
+                    object_info_list.append(readout)
+            except KeyError: continue
+    except KeyError: pass
+    if len(object_info_list) == 0:
+        print('OrcaDecoder::get_readout_info(): warning: no readout info '
+              'for class name', orca_class_name)
+    return object_info_list
+
+
+def get_auxhw_info(header_dict, 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.
+    """
+    object_info_list = []
+    try:
+        objs = header_dict["ObjectInfo"]["AuxHw"]
+        for obj in objs:
+            try:
+                if obj["Class Name"] == orca_class_name:
+                    if unique_id >= 0:
+                        if obj["uniqueID"] != unique_id: continue
+                    object_info_list.append(obj)
+            except KeyError: continue
+    except KeyError: pass
+    if len(object_info_list) == 0:
+        print('OrcaDecoder::get_auxhw_info(): warning: no object info '
+              'for class name', orca_class_name)
     return object_info_list
 
 
@@ -237,7 +286,7 @@ def get_channel(ccc):
 
 # Import orca_digitizers so that the list of OrcaDecoder.__subclasses__ gets populated
 # Do it here so that orca_digitizers can import the functions above here
-from . import orca_digitizers
+from . import orca_digitizers, orca_flashcam
 
 def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=None, verbose=False, buffer_size=1024):
     """
@@ -282,10 +331,10 @@ def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=No
 
     # By default we decode all data for which we have decoders. If the user
     # provides a ch_group_dict, we will only decode data from decoders keyed in
-    # the dict. 
+    # the dict.
     decode_all_data = True
     decoders_to_run = dn2id_dict.keys()
-    if ch_groups_dict is not None: 
+    if ch_groups_dict is not None:
         decode_all_data = False
         decoders_to_run = ch_groups_dict.keys()
 
@@ -312,7 +361,7 @@ def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=No
             print("warning: no decoder exists for", decoder_name, "... will skip its data.")
         else: new_dtr.append(decoder_name)
     decoders_to_run = new_dtr
-    
+
     # prepare ch groups
     if ch_groups_dict is None:
         ch_groups_dict = {}
@@ -333,7 +382,7 @@ def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=No
         ch_groups = ch_groups_dict[decoder_name]
         ch_tables_dict[data_id] = build_tables(ch_groups, buffer_size, dec)
     max_tbl_size = 0
-    
+
     # -- scan over raw data --
     print("Beginning daq-to-raw processing ...")
 
@@ -344,13 +393,20 @@ def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=No
     # reclen is in number of longs, and we want to skip a number of bytes
     f_in.seek(reclen * 4)
 
+    n_entries = 0
+    unit = "B"
+    if n_max < np.inf and n_max > 0:
+        n_entries = n_max
+        unit = "id"
+    else:
+        n_entries = file_size
+    progress_bar = tqdm_range(0, int(n_entries), text="Processing", verbose=verbose, unit=unit)
+    file_position = 0
+
     # start scanning
     while (packet_id < n_max and f_in.tell() < file_size):
         packet_id += 1
 
-        if verbose and packet_id % 1000 == 0:
-            update_progress(float(f_in.tell()) / file_size)
-
         try:
             packet, data_id = get_next_packet(f_in)
         except EOFError:
@@ -366,7 +422,7 @@ def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=No
 
         if data_id not in decoders: continue
         decoder = decoders[data_id]
-        
+
         # Clear the tables if the next read could overflow them.
         # Only have to check this when the max table size is within
         # max_n_rows_per_packet of being full.
@@ -375,7 +431,7 @@ def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=No
             max_tbl_size = 0
             for group_info in ch_groups.values():
                 tbl = group_info['table']
-                if tbl.is_full(): 
+                if tbl.is_full():
                     group_path = group_info['group_path']
                     out_file = group_info['out_file']
                     lh5_store.write_object(tbl, group_path, out_file, n_rows=tbl.loc)
@@ -386,6 +442,14 @@ def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=No
         tables = ch_tables_dict[data_id]
         decoder.decode_packet(packet, tables, packet_id, header_dict)
 
+        if verbose:
+            if n_max < np.inf and n_max > 0:
+                update_len = 1
+            else:
+                update_len = f_in.tell() - file_position
+                file_position = f_in.tell()
+            update_progress(progress_bar, update_len)
+
 
     print("Done. Last packet ID:", packet_id)
     f_in.close()
@@ -401,13 +465,13 @@ def process_orca(daq_filename, raw_file_pattern, n_max=np.inf, ch_groups_dict=No
             print('last write')
             tbl.clear()
 
-    if verbose:
-        update_progress(1)
-
     if len(unrecognized_data_ids) > 0:
         print("WARNING, Found the following unknown data IDs:")
         for data_id in unrecognized_data_ids:
-            print("  {}: {}".format(data_id, id2dn_dict[data_id]))
+            try:
+                print("  {}: {}".format(data_id, id2dn_dict[data_id]))
+            except KeyError:
+                print("  {}: Unknown".format(data_id))
         print("hopefully they weren't important!\n")
 
     print("Wrote RAW File:\n    {}\nFILE INFO:".format(raw_file_pattern))
diff --git a/pygama/raw/raw_buffer.py b/pygama/raw/raw_buffer.py
index 0d1ff6022..d87a977d8 100644
--- a/pygama/raw/raw_buffer.py
+++ b/pygama/raw/raw_buffer.py
@@ -6,13 +6,17 @@
 
 Primary Classes
 ---------------
-RawBuffer: a single lgdo buffer
+RawBuffer: an lgdo (e.g. a table) along with buffer metadata, such as the
+current write location, the list of keys (e.g. channels) that write to it, the
+output stream it is associated with (if any), etc. Each data_decoder is
+associated with a RawBuffer of a particular format.
 
 RawBufferList: a collection of RawBuffers with lgdo's that all have the same
-structure
+structure (same type, same fields, etc). A data_decoder will write its output to
+a RawBufferList.
 
 RawBufferLibrary: a collection (dict) of RawBufferLists, e.g. one for each
-data-generating DAQ object
+data_decoder. Keyed by the decoder name.
 
 RawBuffers support a json short-hand notation, see
 RawBufferLibrary.set_from_json_dict() for full specification.
@@ -22,7 +26,8 @@
 kw_dict containing an entry for 'file_key'. The other keywords {key} and {name}
 are understood by and filled in during set_from_json_dict() unless overloaded in
 kw_dict. Note the use of the wildcard "*": this will match all other decoder
-names / keys
+names / keys.
+
 {
   "FlashCamEventDecoder" : {
     "g{key:0>3d}" : {
@@ -31,7 +36,7 @@
     },
     "spms" : {
       "key_list" : [ [6,23] ],
-      "out_stream" : "$DATADIR/{file_key}_spms.lh5"
+      "out_stream" : "$DATADIR/{file_key}_spms.lh5/sipms"
     },
     "puls" : {
       "key_list" : [ 0 ],
@@ -61,18 +66,19 @@ class RawBuffer:
     '''
     A RawBuffer is in essence a an lgdo object (typically a Table) to which
     decoded data will be written, along with some meta-data distinguishing
-    what data goes into it, and where the lgdo gets written out
+    what data goes into it, and where the lgdo gets written out. Also holds on
+    to the current location in the buffer for writing.
 
     Attributes
     ----------
     lgdo : lgdo 
         the lgdo used as the actual buffer. Typically a table. Set to None upon
-        creation so that the user can initialize it later.
+        creation so that the user or a decoder can initialize it later.
     key_list : list
         a list of keys (e.g. channel numbers) identifying data to be written
         into this buffer. The key scheme is specific to the decoder with which
-        the raw_group is associated. This is called "key_list" instead of "keys"
-        to avoid confusion with the dict function "keys()", i.e. raw_group.keys()
+        the RawBuffer is associated. This is called "key_list" instead of "keys"
+        to avoid confusion with the dict function "keys()", i.e. raw_buffer.lgdo.keys()
     out_stream : str (optional)
         the output stream to which the raw_buffer's lgdo should be sent or
         written. A colon can be used to separate the stream name/address from an
@@ -97,37 +103,13 @@ def __len__(self):
         return len(lgdo)
 
 
-    def make_lgdo(self, maker, size=None):
-        ''' Make this buffer's lgdo
-
-        Uses the first key in key_list for initialization
-
-        Parameters
-        ----------
-        maker : obj
-            an object with an make_lgdo(key, size) function that can be
-            used to initialize the lgdo (e.g. set up the columns of the Table)
-        '''
-        key = None if len(self.key_list) == 0 else self.key_list[0]
-        self.lgdo = maker.make_lgdo(key=key, size=size)
-
-
-
 class RawBufferList(list):
     '''
-    A RawBufferDict holds a collection of RawBuffers of identical structure
-    (same format lgdo's). 
+    A RawBufferList holds a collection of RawBuffers of identical structure
+    (same format lgdo's with the same fields). 
     '''
 
 
-    def make_lgdos(self, maker, size=None):
-        ''' Make the lgdos in this raw buffer list 
-
-        See RawBuffer.make_lgdo for parameter info
-        '''
-        for rb in self: rb.make_lgdo(maker, size=size)
-
-
     def get_keyed_dict(self, default=None):
         ''' returns a dict of RawBuffers built from the buffers' key_lists
 
diff --git a/pygama/version.py b/pygama/version.py
new file mode 100644
index 000000000..777f190df
--- /dev/null
+++ b/pygama/version.py
@@ -0,0 +1,1 @@
+__version__ = "0.8.0"
diff --git a/setup.py b/setup.py
index 933a1d092..8e899e418 100755
--- a/setup.py
+++ b/setup.py
@@ -127,9 +127,16 @@ def run(self):
         clean_jupyter_notebooks()
         develop.run(self)
 
+# avoid having to manually specify the version in multiple places
+def get_version():
+    g = {}
+    with open(os.path.join("pygama", "version.py")) as f:
+        exec(f.read(), g)
+    return g["__version__"]
+
 setup(
     name='pygama',
-    version='0.5',
+    version=get_version(),
     author='LEGEND',
     author_email='wisecg@uw.edu',
     description='Python package for decoding and processing digitizer data',
@@ -144,7 +151,8 @@ def run(self):
     },
     install_requires=[
         'numpy',
-        'numba',
+        'scimath',
+        'numba !=0.53, !=0.54',
         'parse',
         'GitPython',
         'tinydb',
@@ -153,8 +161,9 @@ def run(self):
         'pandas',
         'matplotlib',
         'pytest',
-        'pint'
-
+        'pint',
+        'iminuit',
+        'tqdm'
         # 'fcutils @ https://github.com/legend-exp/pyfcutils.git#egg=1.0.0'
     ],
     cmdclass=dict(build_ext=CMakeBuild, build_py=PygamaBuild, develop=PygamaDev),
diff --git a/tutorials/DataProcessing.ipynb b/tutorials/DataProcessing.ipynb
new file mode 100644
index 000000000..3a8640f0c
--- /dev/null
+++ b/tutorials/DataProcessing.ipynb
@@ -0,0 +1,163 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "a18b7b84-6bbc-4e89-bcbf-d20e851ec520",
+   "metadata": {},
+   "source": [
+    "# Data Processing and Access 101\n",
+    "\n",
+    "Ian Guinn, UNC. Presented at [LEGEND Software Tutorial, Nov. 2021](https://indico.legend-exp.org/event/561/)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6dab0cfd-4867-4836-b80a-d36f91d71909",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Set up python environment\n",
+    "from pygama.io.daq_to_raw import daq_to_raw\n",
+    "from pygama.io.raw_to_dsp import raw_to_dsp\n",
+    "from pygama.lh5.store import *\n",
+    "\n",
+    "daq_file = '/global/cfs/cdirs/m2676/data/legend-testdata/data/cage/2021-1-16-CAGERun1250'\n",
+    "raw_file = \"metadata/CAGERun1250_raw.lh5\"\n",
+    "dsp_file = \"metadata/CAGERun1250_dsp.lh5\"\n",
+    "\n",
+    "dsp_config = \"./metadata/dsp_config.json\""
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "d1e2ba70-7278-439b-aacc-dfb470ae689a",
+   "metadata": {},
+   "source": [
+    "## Running daq_to_raw\n",
+    "\n",
+    "Our first step in processing is to run daq_to_raw, which will decode the binary file produced by our DAQ system and output an HDF5 file following LEGEND's lh5 file specification. This requires us to provide an input file, an output file name, and a dictionary of settings. Because the CAGE file we are using comes from ORCA, the config is extremely simple; for other DAQ systems, this can get more complex."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1709baf9-87d6-41fe-8973-fdc851b75ac7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# Config would normally be provided as a JSON file\n",
+    "d2r_config = {\n",
+    "    'daq':'ORCA'\n",
+    "}\n",
+    "\n",
+    "daq_to_raw(daq_file,\n",
+    "           raw_file,\n",
+    "           config=d2r_config)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "e8aa7e3d-8393-47f7-b0fc-0483648af5b0",
+   "metadata": {},
+   "source": [
+    "## Inspecting the raw file\n",
+    "\n",
+    "Next, we'll look at the file output from daq_to_raw. The file is output using the LH5 specification, and can be accessed using the pygama.lh5.store module.\n",
+    "\n",
+    "First, we'll create a Store object, and call ls to list the contents of the hdf group containing our data. Then, we'll call load_dfs to create a pandas dataframe. Note that the pandas dataframe will not contain the waveforms."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b640db89-baf5-47c6-9b4e-e71ee0c48c69",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "lh5_st = Store()\n",
+    "print(\"List of raw file elements:\")\n",
+    "print(lh5_st.ls(raw_file, 'ORSIS3302DecoderForEnergy/raw/'))\n",
+    "\n",
+    "print()\n",
+    "print(\"Data from file:\")\n",
+    "raw_df = load_dfs(raw_file,\n",
+    "                  par_list = ['card', 'channel', 'crate', 'energy', 'energy_first',\n",
+    "                              'ievt', 'packet_id', 'timestamp'],\n",
+    "                  lh5_group = 'ORSIS3302DecoderForEnergy/raw/')\n",
+    "print(raw_df)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6eb2240b-f4c4-44f3-a252-8d5a0035b888",
+   "metadata": {},
+   "source": [
+    "## Running raw_to_dsp\n",
+    "\n",
+    "The next stop in our processing is to run raw_to_dsp, which will run a sequence of digital signal processors and output the results into another lh5 file. These processors are set up using a dictionary which will be provided by a JSON file. For more details about how to set up this file, see other tutorials.\n",
+    "\n",
+    "Many processors benefit from having optimized parameters for each channel and run range. This optimization process is not covered by this tutorial, but the parameters are stored in a the metadata database, and is provided to raw_to_dsp using a json file or dict. In this example, we will provide the pole-zero correction time constant in this way."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "4f31981c-26ff-49ef-adc9-6a57b8dc6927",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "db_dict = {'ORSIS3302DecoderForEnergy': {\n",
+    "    'pz': { 'tau':\"48*us\" }\n",
+    "    }\n",
+    "}\n",
+    "\n",
+    "raw_to_dsp(raw_file, dsp_file, dsp_config,\n",
+    "           database = db_dict)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "53012d1c-d539-4ede-9170-2103823105af",
+   "metadata": {},
+   "source": [
+    "## Inspecting the dsp file"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "06219ef8-ad9a-496b-a735-6e5f2de8ce04",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\"List of raw file elements:\")\n",
+    "print(lh5_st.ls(dsp_file, 'ORSIS3302DecoderForEnergy/dsp/'))\n",
+    "\n",
+    "print()\n",
+    "print(\"Data from file:\")\n",
+    "raw_df = load_dfs(dsp_file,\n",
+    "                  par_list = ['trapEmax', 'bl_mean', 'timestamp', 'AoE'],\n",
+    "                  lh5_group = 'ORSIS3302DecoderForEnergy/dsp/')\n",
+    "print(raw_df)\n",
+    "\n",
+    "raw_df.hist('trapEmax', bins=2000)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "94f59e52-73e4-4de4-8999-d2f5cba1da1a",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "name": "python"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/tutorials/HADESDataTutorial.ipynb b/tutorials/HADESDataTutorial.ipynb
new file mode 100644
index 000000000..cd1e5861c
--- /dev/null
+++ b/tutorials/HADESDataTutorial.ipynb
@@ -0,0 +1,789 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# HADES Data Tutorial"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "George Marshall, UCL.  Presented at [LEGEND Software Tutorial, Nov. 2021](https://indico.legend-exp.org/event/561/)\n",
+    "\n",
+    "The most up to date data is located at MPIK in the ggmarsh-test-v03 user production. Hopefully in the next few months a new version will be produced probably at Gran Sasso and copied elsewhere which will the first reference production."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## The LEGEND Production Environment"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "A full set of tutorials on the production environment can be found here: https://github.com/mmatteo/legend-analysis-tutorials . \n",
+    "The github page which provides more detail on all the commands is here: https://github.com/legend-exp/legend-prodenv ."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Once you have navigated to the production environment at `/lfs/l1/legend/legend-prodenv` , the setup file can be sourced using:"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "`source setup.sh`"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "This gives access to all the prodenv commands a full desciption of which are found above."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Also found here are the 2 types of production : reference productions and user productions. Currently only user productions are being made but hopefully soon we will have a stable full production pipeline and will then produce the first reference production. The idea is that reference productions will be run using stable versions of pygama whereas the user productions are more experimental."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "This tutorial will focus on the data found at `/lfs/l1/legend/legend-prodenv/prod-usr/ggmarsh-test-v03`."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "To load the software for this production cycle we can use:\n",
+    "`prodenv-load.sh config.json`"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "This will put us in the container for this environment with the version of pygama the data was made using. As mentioned earlier for the user productions these may not be stable versions of pygama."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## A Production Cycle"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "By default there are 8 directories in each production cycle. "
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "dataflow contains all the snakmake code which controls the data production"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "gen is where all the data produced is stored, each detector has a separate directory in which is each tier of production. At the moment data is produced up to and including tier2."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "genpar contains parameters produced in the data production such as the pole zero constant, calibration constants etc."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "log contains the all the logs for the data production"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "meta contains all the config files for data productions"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "src contains all the other software such as the pygama version."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "venv is the virtual environment"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Data Fields"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Tier1 data contains the following fields:"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "```console\n",
+    "$ raw.lh5\n",
+    "├── stat\n",
+    "└── raw\n",
+    "     ├── baseline    # FPGA-estimated baseline\n",
+    "     ├── channel     # right now, index of the trigger (trace)\n",
+    "     ├── energy      # FPGA-estimated energy\n",
+    "     ├── ievt        # index of event\n",
+    "     ├── numtraces   # number of triggered FADC channels\n",
+    "     ├── packet_id   # packet index in file\n",
+    "     ├── timestamp   # time since beginning of file\n",
+    "     ├── tracelist   # list of triggered FADC channels\n",
+    "     ├── waveform    # digitizer data\n",
+    "     │   ├── dt      # sampling period (ns) - 16\n",
+    "     │   ├── t0\n",
+    "     │   └── values  # array holding the waveform samples FADC units\n",
+    "     ├── wf_max      # ultra-simple np.max energy estimation\n",
+    "     └── wf_std      # ultra-simple np.std noise estimation\n",
+    "\n",
+    "```"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Whereas tier 2 contains a few more:"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "```console\n",
+    "$ dps.lh5\n",
+    "├── dsp_info\n",
+    "└── raw\n",
+    "     ├── A_max\n",
+    "     ├── QDrift\n",
+    "     ├── bl_intercept\n",
+    "     ├── bl_mean\n",
+    "     ├── bl_slope\n",
+    "     ├── bl_std\n",
+    "     ├── cuspEftp\n",
+    "     ├── cuspEftp_ctc\n",
+    "     ├── cuspEmax\n",
+    "     ├── cuspEmax_ctc\n",
+    "     ├── dt_eff\n",
+    "     ├── pz_mean\n",
+    "     ├── pz_slope\n",
+    "     ├── pz_std\n",
+    "     ├── tp_01\n",
+    "     ├── tp_0_est\n",
+    "     ├── tp_0_trap\n",
+    "     ├── tp_10\n",
+    "     ├── tp_100\n",
+    "     ├── tp_20\n",
+    "     ├── tp_50\n",
+    "     ├── tp_80\n",
+    "     ├── tp_90\n",
+    "     ├── tp_95\n",
+    "     ├── tp_99\n",
+    "     ├── tp_max\n",
+    "     ├── tp_min\n",
+    "     ├── trapEftp\n",
+    "     ├── trapEftp_ctc\n",
+    "     ├── trapEmax\n",
+    "     ├── trapEmax_ctc\n",
+    "     ├── trapTmax\n",
+    "     ├── wf_max\n",
+    "     ├── wf_min\n",
+    "     ├── zacEftp\n",
+    "     ├── zacEftp_ctc\n",
+    "     ├── zacEmax\n",
+    "     └── zacEmax_ctc\n",
+    "```"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "A full description of how all this data was produced can be found here: https://indico.legend-exp.org/event/698/contributions/3409/attachments/1852/2844/Processing_Chain_lnote_v1.pdf"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "bl_mean, bl_slope, bl_std, bl_intercept are parameters related to the baseline namely the mean, RMS, slope and intercept."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "pz_mean, pz_slope, pz_std are the same but for the flat top"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "tp_01, tp_0_est, tp_0_trap, tp_10, tp_100, tp_20, tp_50, tp_80, tp_90, tp_95, tp_99, tp_max, tp_min are all timepoint parameters. tp_0_est and tp_0_trap are two different estimates of the start point of the signal. All others are times to reach the relevant percentage of the energy. Finally tp_max and tp_min are the timepoints of the max and min of the waveform respectively."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "cuspEftp, cuspEftp_ctc, cuspEmax, cuspEmax_ctc,\n",
+    "trapEftp, trapEftp_ctc, trapEmax, trapEmax_ctc, trapTmax, \n",
+    "zacEftp, zacEftp_ctc, zacEmax, zacEmax_ctc\n",
+    "are all energy estimates. There are three different filter trap, zac and cusp. All filters have been optimised.\n",
+    "For each we have 2 different extraction methods the max and fixed time pickoff (ftp). \n",
+    "For the moment I would recommend using the max as it performs better for this data.\n",
+    "Lastly there are two types ctc and no ctc which is whether the charge trapping correction is included or not.\n",
+    "trapTmax is a fixed length trap filter used for timing estimates."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Other parameters:\n",
+    "A_max  is the max current, \n",
+    "QDrift is a parameter related to the uncollected charge. When divided by the energy (trapTmax) we get an effective drift time, dt_eff.\n",
+    "Finally wf_max and wf_min are the max and min of the waveform."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Loading in Data"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Load in packages"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pygama.lh5 as lh5\n",
+    "import os,json\n",
+    "import matplotlib.pyplot as plt\n",
+    "import numpy as np"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib notebook"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Find files in this case for a Th lateral scan"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# TODO: update these with paths appropriate to NERSC, ideally this directory:\n",
+    "# /global/cfs/cdirs/m2676/data/hades/V07646A/tier2/th_HS2_lat_psa\n",
+    "# George will edit this soon & remove this message.  --Clint\n",
+    "\n",
+    "det = 'V07646A'\n",
+    "datatype = 'th_HS2_lat_psa'\n",
+    "run = '001'\n",
+    "datapath = '/lfs/l1/legend/legend-prodenv/prod-usr/ggmarsh-test-v03/gen/'+det+'/tier2/'+datatype"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "files      = os.listdir(datapath)\n",
+    "files.sort()\n",
+    "for i,file in enumerate(files):\n",
+    "    files[i] = os.path.join(datapath,file)\n",
+    "files = files[0:5]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sto=lh5.Store()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We can use this command to check what is in the file"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "sto.ls(files[0], 'raw/')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Load data in"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "uncal = lh5.load_nda(files, ['cuspEmax','cuspEmax_ctc'], 'raw')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(uncal)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Or we can use dataframes instead:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df = lh5.load_dfs(files, ['cuspEmax','cuspEmax_ctc'], 'raw')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df.head()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plt.figure()\n",
+    "counts,bins,bars = plt.hist(uncal['cuspEmax'], bins=10000, histtype='step', label='No Charge Trapping Correction')\n",
+    "plt.hist(uncal['cuspEmax_ctc'], bins=bins, histtype='step', label='Charge Trapping Corrected')\n",
+    "plt.yscale('log')\n",
+    "plt.legend()\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Applying Quality Cuts"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pygama.genpar_tmp.cuts as cts"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "uncal_pass, uncal_cut = cts.load_nda_with_cuts(files,'raw',['cuspEmax_ctc'],verbose=False)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "By default the cuts are generated as a 4 sigma double sided cut on bl_mean, bl_std and pz_std. We can customize by specifying a cut dictionary."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "other_cuts_pass, other_cuts_fail  = cts.load_nda_with_cuts(files,'raw',['cuspEmax_ctc'], \n",
+    "                                               cut_parameters= {'bl_mean':5,'bl_std':{'left':10,'right':4}, 'pz_std':4},\n",
+    "                                                          verbose=False)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plt.figure()\n",
+    "counts,bins,bars = plt.hist(uncal_pass['cuspEmax_ctc'], bins=1000, histtype='step', label='Passed Cuts')\n",
+    "plt.hist(uncal_cut['cuspEmax_ctc'], bins=bins, histtype='step', label='Failed Cuts')\n",
+    "plt.yscale('log')\n",
+    "plt.legend()\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Note: Some detectors have problems with the default cuts as they could not use the DAQ baseline for subtraction. These detectors are V05266A and V04549A, for these just remove bl_mean from the cut dictionary. Also V07647B has a change in baseline value so the first file of run one shouldn't be used."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Applying Energy Calibration "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pygama.analysis.calibration as cal\n",
+    "import pygama.analysis.peak_fitting as pgp"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "glines    = [583.191, 727.330, 860.564,1592.53,1620.50,2103.53,2614.50] # gamma lines used for calibration\n",
+    "range_keV = [(25,40),(25,40), (25,40),(25,20),(25,40),(25,40),(90,90)] # side bands width\n",
+    "funcs = [pgp.radford_peak,pgp.radford_peak,pgp.radford_peak,\n",
+    "             pgp.radford_peak,pgp.radford_peak,pgp.radford_peak,pgp.radford_peak]"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For the calibration we have to specify the peaks to find in keV. The widths around each peak to fit again in keV and finally the function to try and fit to each peak. Here we are just using the radford_peak function which is a gaussian with low energy tail and a step function for the background."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The other argument we have to supply is a guess on the adc to kev conversion. For Th assuming the 99th percentile is around the 2615 peak works well."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pars, cov, results = cal.hpge_E_calibration(\n",
+    "    uncal_pass['cuspEmax_ctc'],\n",
+    "    glines,\n",
+    "    (2620/np.nanpercentile(uncal_pass['cuspEmax_ctc'],99)),\n",
+    "    deg=1,\n",
+    "    range_keV = range_keV,\n",
+    "    funcs = funcs,\n",
+    "    verbose=True\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ecal_pass = pgp.poly(uncal_pass['cuspEmax_ctc'], pars)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plt.figure()\n",
+    "plt.hist(ecal_pass, bins=10000, histtype='step')\n",
+    "plt.xlabel(\"Energy (adc)\",     ha='right', x=1)\n",
+    "plt.ylabel(\"Counts / keV\",     ha='right', y=1)\n",
+    "plt.yscale('log')\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Some tips: if the routine is struggling to find the peaks then the issue is probably the guess parameter. Here this is specified as `(2600/np.nanpercentile(uncal_pass['cuspEmax_ctc'],99))` where we are guessing that the 99th percentile is around the 2615 peak. This works well for Th but will need different values for other sources. If the peak fitting is struggling try changing the fit widths in the range_keV above."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The results is a dictionary  containing the location of the found peaks in adc and kev, the location of the fitted peaks in kev and the parameters of the fitted peaks. Finally there is the calibration parameters and the fwhms of the peaks."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(results)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Alternatively for Th data we can load the energy calibration constants from the ecal files. Note: These won't apply to lower energy sources. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "calpath = '/lfs/l1/legend/legend-prodenv/prod-usr/ggmarsh-test-v03/genpar/dsp_ecal/'+det+'.json'\n",
+    "with open(calpath, 'r') as o:\n",
+    "    cal_dict = json.load(o)\n",
+    "cal_pars = cal_dict['cuspEmax_ctc']['Calibration_pars']\n",
+    "eres_pars = [cal_dict['cuspEmax_ctc']['m0'], cal_dict['cuspEmax_ctc']['m1']]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "plt.figure()\n",
+    "plt.hist(pgp.poly(uncal_pass['cuspEmax_ctc'], cal_pars), bins=10000, histtype='step')\n",
+    "plt.xlabel(\"Energy (adc)\",     ha='right', x=1)\n",
+    "plt.ylabel(\"Counts / keV\",     ha='right', y=1)\n",
+    "plt.yscale('log')\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Peak Fitting"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "In pygama there are a number of convenience functions for histogramming and peak fitting. These use by default a least squares fit for binned data. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pygama.analysis.histograms as pgh"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "First we can use the get_hist function to generate the histogram. There are a number of ways to do this you can either specify the number of bins, the edges of the bins or the range and width of the bins."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "hist,bins,var = pgh.get_hist(ecal_pass[(ecal_pass>2554)&(ecal_pass<2664)], range=(2554,2664), dx=0.1)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The fitting function is fit_hist. It takes in the function to fit, the data and then a parameter guess to start the fitting. It can also take bounds on these parameters. For energy fitting guessing functions have been implemented so we will use these."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We will use again the radford peak function to fit which is a gaussian with a tail and a step. A number of other peak shapes are implemented in pygama and can be found [here](https://github.com/legend-exp/pygama/blob/master/pygama/analysis/peak_fitting.py). For this fit function, initial guesses have been implemented so we will use these."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pars_guess = cal.get_hpge_E_peak_par_guess(hist, bins, var, pgp.radford_peak)\n",
+    "bounds = cal.get_hpge_E_peak_bounds(hist, bins, var, pgp.radford_peak, pars_guess)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pars,covs = pgp.fit_hist(pgp.radford_peak, hist,bins, guess=pars_guess, bounds=bounds)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "bin_cs = (bins[1:]+bins[:-1])/2\n",
+    "plt.figure()\n",
+    "ax1 = plt.subplot(211)\n",
+    "plt.xlim([2600,2620])\n",
+    "ax1.tick_params('x', labelbottom=False)\n",
+    "plt.plot(bin_cs,hist)\n",
+    "plt.plot(bin_cs,pgp.radford_peak(bin_cs, *pars))\n",
+    "plt.ylabel(\"Counts\")\n",
+    "ax2 = plt.subplot(212, sharex=ax1)\n",
+    "plt.plot(bin_cs,pgp.radford_peak(bin_cs, *pars)-hist)\n",
+    "\n",
+    "plt.xlabel(\"Energy (keV)\")\n",
+    "plt.ylabel(\"Residual\")\n",
+    "plt.tight_layout()\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "If using other fit functions there are a number of other helpful functions in pygama to guess parameter values. If fitting a gaussian we can use gauss_mode_width_max to guess the mu, sigma and max. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "g_pars, g_covs = pgp.gauss_mode_width_max(hist,bins)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(f'Mu guess is {g_pars[0]}, fitted is {pars[0]}')\n",
+    "print(f'Sigma guess is {g_pars[1]}, fitted is {pars[1]}')\n",
+    "print(f'Max guess is {g_pars[2]}, fitted is {pars[-1]}')"
+   ]
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "name": "python"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/tutorials/IntroToDSP.ipynb b/tutorials/IntroToDSP.ipynb
new file mode 100644
index 000000000..a6a3362ff
--- /dev/null
+++ b/tutorials/IntroToDSP.ipynb
@@ -0,0 +1,405 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Introduction to Digital Signal Processing\n",
+    "\n",
+    "Ian Guinn, UNC.  Presented at [LEGEND Software Tutorial, Nov. 2021](https://indico.legend-exp.org/event/561/)\n",
+    "\n",
+    "This notebook will provide a brief introduction to a few of LEGEND's bread and butter digital signal processing filters. These processors will be applied in sequence to digitized waveforms that are recorded to disk, and a brief explanation of why we use them is provided. The parameters of each processor may be varied as well in order to see the affects that they have. This goes along with C. Wiseman and I. Guinn's slides shown at the Early Career Showcase: https://indico.legend-exp.org/event/699/. Reccomended reading is Knoll chapter 17, which introduces many pulse-shaping concepts (although, from a hardware-perspective more than a digital signal processing perspective; even so, the concepts are similar).\n",
+    "\n",
+    "### Set up the python environment\n",
+    "It is recommended that you use a file from the LEGEND test-data repository: https://github.com/legend-exp/legend-testdata; you may need to change data_file if you want to run this notebook yourself."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#%matplotlib notebook\n",
+    "\n",
+    "from pygama.lh5.store import Store\n",
+    "from pygama.dsp.WaveformBrowser import WaveformBrowser\n",
+    "import pygama.dsp.units as units\n",
+    "import matplotlib.pyplot as plt\n",
+    "import os\n",
+    "\n",
+    "# data_file = os.path.expandvars(\"$LEGENDTESTDATA/data/lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5\")\n",
+    "data_file = '/global/cfs/cdirs/m2676/data/legend-testdata/data/lh5/LDQTA_r117_20200110T105115Z_cal_geds_raw.lh5'\n",
+    "entry_no = 2 # which waveform in data_file to use\n",
+    "\n",
+    "plt.rcParams[\"figure.figsize\"]=(9, 6)\n",
+    "plt.rcParams[\"font.size\"] = 16\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Processor Setup\n",
+    "This next panel defines all of the processors that we are going to run. Normally this is done in a JSON file in almost the exact same format shown here. You are encouraged to try changing some of the values in these processors to see the effect on the analysis"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dsp_config = { \"processors\": {\n",
+    "\n",
+    "    \"max_index\":{\n",
+    "        \"function\": \"argmax\",\n",
+    "        \"module\": \"numpy\",\n",
+    "        \"args\": [\"waveform\", 1, \"max_index\"],\n",
+    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
+    "        \"unit\": \"ADC\",\n",
+    "        \"prereqs\":[\"waveform\"]\n",
+    "    },\n",
+    "    \n",
+    "    \"bl , bl_sig, slope, intercept\":{\n",
+    "        \"function\": \"linear_slope_fit\",\n",
+    "        \"module\": \"pygama.dsp.processors\",\n",
+    "        \"args\" : [\"waveform[:1650]\", \"bl\",\"bl_sig\", \"slope\",\"intercept\"],\n",
+    "        \"unit\": [\"ADC\",\"ADC\",\"ADC\",\"ADC\"]\n",
+    "    },\n",
+    "\n",
+    "    \"wf_blsub\":{\n",
+    "        \"function\": \"subtract\",\n",
+    "        \"module\": \"numpy\",\n",
+    "        \"args\": [\"waveform\", \"bl\", \"wf_blsub\"],\n",
+    "        \"unit\": \"ADC\"\n",
+    "    },\n",
+    "\n",
+    "\n",
+    "    \"wf_pz\": {\n",
+    "        \"function\": \"pole_zero\",\n",
+    "        \"module\": \"pygama.dsp.processors\",\n",
+    "        \"args\": [\"wf_blsub\", \"180*us\", \"wf_pz\"],\n",
+    "        \"unit\": \"ADC\",\n",
+    "    },\n",
+    "\n",
+    "    \"wf_trap\": {\n",
+    "        \"function\": \"trap_norm\",\n",
+    "        \"module\": \"pygama.dsp.processors\",\n",
+    "        \"args\": [\"wf_pz\", \"8*us\", \"2*us\", \"wf_trap\"],\n",
+    "        \"unit\": \"ADC\"\n",
+    "    },\n",
+    "    \n",
+    "    \"trapEmax\": {\n",
+    "        \"function\": \"amax\",\n",
+    "        \"module\": \"numpy\",\n",
+    "        \"args\": [\"wf_trap\", 1, \"trapEmax\"],\n",
+    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->f\"]},\n",
+    "        \"unit\": \"ADC\",\n",
+    "    },\n",
+    "\n",
+    "    \"trapTmax\": {\n",
+    "        \"function\": \"argmax\",\n",
+    "        \"module\": \"numpy\",\n",
+    "        \"args\": [\"wf_trap\", 1, \"trapTmax\"],\n",
+    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
+    "        \"unit\": \"ns\"\n",
+    "    },\n",
+    "\n",
+    "    \"wf_atrap\": {\n",
+    "        \"function\": \"asym_trap_filter\",\n",
+    "        \"module\": \"pygama.dsp.processors\",\n",
+    "        \"args\": [\"wf_pz\", \"round(0.1*us)\", \"round(1*us)\", \"round(3*us)\", \"wf_atrap\"], # rise, flat, fall\n",
+    "        \"unit\": \"ADC\"\n",
+    "    },\n",
+    "    \n",
+    "    \"tmax\": {\n",
+    "        \"function\": \"argmax\",\n",
+    "        \"module\": \"numpy\",\n",
+    "        \"args\": [\"wf_atrap\", 1, \"tmax\"],\n",
+    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
+    "        \"unit\": \"ns\"\n",
+    "    },\n",
+    "    \n",
+    "    \"tp_0\": {\n",
+    "        \"function\": \"time_point_thresh\",\n",
+    "        \"module\": \"pygama.dsp.processors\",\n",
+    "        \"args\": [\"wf_atrap\", 0, \"tmax\", 0, \"tp_0\"],\n",
+    "        \"unit\": \"ns\",\n",
+    "    },\n",
+    "    \n",
+    "    \"ftp\": {\n",
+    "        \"function\": \"add\",\n",
+    "        \"module\": \"numpy\",\n",
+    "        \"args\": [\"tp_0\", \"round(2*us+8*us-0.25*us)\", \"ftp\"],\n",
+    "        \"unit\": \"ns\"\n",
+    "    },\n",
+    "    \n",
+    "    \"trapEftp\": {\n",
+    "        \"function\": \"fixed_time_pickoff\",\n",
+    "        \"module\": \"pygama.dsp.processors\",\n",
+    "        \"args\": [\"wf_trap\", \"ftp\", \"trapEftp\"],\n",
+    "        \"unit\": \"ADC\",\n",
+    "    },\n",
+    "    \n",
+    "    \"curr\": {\n",
+    "        \"function\": \"avg_current\",\n",
+    "        \"module\": \"pygama.dsp.processors\",\n",
+    "        \"args\": [\"wf_pz\", 1, \"curr(len(wf_pz)-1, f)\"],\n",
+    "        \"unit\": \"ADC\",\n",
+    "    },\n",
+    "    \n",
+    "    \"curr_gaus\": {\n",
+    "        \"function\": \"gaussian_filter1d\",\n",
+    "        \"module\": \"pygama.dsp._processors.gaussian_filter1d\",\n",
+    "        \"args\": [\"curr\", \"curr_gaus(len(curr), f)\"],\n",
+    "        \"init_args\": [\"50*ns\", 4.],\n",
+    "        \"unit\": \"ADC\",\n",
+    "    },\n",
+    "    \"A_gaus\": {\n",
+    "        \"function\": \"amax\",\n",
+    "        \"module\": \"numpy\",\n",
+    "        \"args\": [\"curr_gaus\", 1, \"A_gaus\"],\n",
+    "        \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->f\"]},\n",
+    "        \"unit\": \"ADC\",\n",
+    "    },\n",
+    "    \n",
+    "    \n",
+    "    }   \n",
+    "}"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Raw Waveform\n",
+    "This is a waveform as produced by a flashcam digitizer. The waveform can be divided into three regions; first, the baseline, the flat part up to ~45000 ns. Next the rising edge, where it increases at ~45000 ns. Lastly, the falling tail, which is an approximately exponential return to baseline that begins after ~45000 ns"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(data_file, \"geds/raw\")\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Baseline Subtraction\n",
+    "The first processors applied perform baseline subtraction. First we measure the baseline using the first 1650 samples of the waveform, and then we subtract the baseline from each sample."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
+    "                          dsp_config=dsp_config,\n",
+    "                          waveforms=[\"waveform\", \"wf_blsub\"],\n",
+    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}])\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Pole-Zero Correction\n",
+    "The next step is to correct our waveform for the electronics response, which produces the exponential shape characteristic of our waveforms. The goal of this transform is to return a waveform with a perfectly flat tail. The pole-zero correction assumes a simple electronics response characterized by a single RC constant (in this case 180 us). In reality, our electronics can be characterized by multiple RC constants; as a result we do not achieve a perfectly flat top, but by applying more complex transforms at this step we can improve things. The pole-zero applied here is close enough for most applications, though. Knoll chapter 17 sec 7B has a more complete description."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
+    "                          dsp_config=dsp_config,\n",
+    "                          waveforms=[\"wf_blsub\", \"wf_pz\"],\n",
+    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
+    "                          x_lim=(40000, 70000))\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Trapezoidal Filter\n",
+    "The trapezoidal filter is a nearly optimal filter that is used to extract pulse amplitude for a variety of applications. The trapezoid is the difference, at each point, between the average over two sets of samples separated by a short time. It can be characterized by:\n",
+    "- Integration time: number of samples averaged, and the length of the rising and falling edges of the trapezoid. We use 8 us here. A longer integration time will reduce the effect of noise on our energy measurement (up to a limit that depends on low frequency noise; it also can't be too long for the number of samples collected).\n",
+    "- Flat top time: the separation between the integration regions, and the length of the flat top of the trapezoid. This should be longer than the rising edge of the waveform; if it is not, the top will not be totally flat and we will measure a reduction in the charge called \"ballistic deficit.\" We also don't want the flat top to be too long, or else low frequency noise will degrade our resolution. Here, we use 2 us as our flat top time.\n",
+    "\n",
+    "Knoll chapter 17 section 7 has a description of trapezoidal shaping."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
+    "                          dsp_config=dsp_config,\n",
+    "                          waveforms=[\"wf_pz\", \"wf_trap\"],\n",
+    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
+    "                          x_lim=(40000, 70000))\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Energy estimation: trap-max\n",
+    "The most common way to measure uncalibrated energy is to use the maximum of the trapezoidal filter. This works fairly well, assuming you have a nice flat top; however, due to our imperfect pole-zero correction, this can result in a degredation in our energy resolution"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
+    "                          dsp_config=dsp_config,\n",
+    "                          waveforms=[\"wf_trap\"],\n",
+    "                          wf_styles=[{'color':['r']}],\n",
+    "                          lines=[\"trapEmax\", \"trapTmax\"],\n",
+    "                          x_lim=(40000, 70000))\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Energy estimation: fixed time pick-off\n",
+    "Another way to measure energy is to pick-off the amplitude of the trapezoidal filter at a fixed time relative to the start of the waveform. The fixed time should fall on the flat top of the waveform, with enough padding to avoid ballistic deficit, and to prevent noise in the start time from pushing the pick-off time onto the falling edge of the waveform. We choose a pickoff time 0.25 us before the falling edge begins. This technique also requires an accurate measurement of the start time (t0) of the waveform (see next panel...)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
+    "                          dsp_config=dsp_config,\n",
+    "                          waveforms=[\"wf_trap\"],\n",
+    "                          wf_styles=[{'color':['r']}],\n",
+    "                          lines=[\"tp_0\", \"ftp\", \"trapEftp\"],\n",
+    "                          x_lim=(40000, 70000))\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Start time measurement\n",
+    "The start time (t0) of the waveform must be measured in order to apply the fixed-time pickoff. We use the following technique to measure this:\n",
+    "\n",
+    "1) Apply an asymmetric trapezoidal filter, which uses a short integration time for the rising edge and a long integration time for the falling tail. The short rising edge has the advantage of preserving most timing information in the rising edge. The longer tail helps us correct for low frequency noise. Here, we use 0.1-1-3] us as the rise-flat-fall times.\n",
+    "\n",
+    "2) Find the maximum of the asymmetric-trap, and walk back from there until we cross zero. The first sample that we cross this threshold is our t0.\n",
+    "\n",
+    "An accurate t0 is important both for the fixed-time pickoff, and for various drift-time corrections (such as for charge-trapping) that are not described in this notebook. While this filter is mostly reliable, it can occasionally fail for low signal-to-noise waveforms, or waveforms with large transiant blips in the noise."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
+    "                          dsp_config=dsp_config,\n",
+    "                          waveforms=[\"wf_pz\", \"wf_atrap\"],\n",
+    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
+    "                          lines=[\"tp_0\"],\n",
+    "                          x_lim=(40000, 70000))\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Current measurement\n",
+    "Current is needed to measure A/E. This can be found using a simple derivative filter. However, the signal-to-noise using just the derivative can be quite high, so we often apply additional smoothing filters after (next panel...)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
+    "                          dsp_config=dsp_config,\n",
+    "                          waveforms=[\"wf_pz\", \"curr\"],\n",
+    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
+    "                          x_lim=(40000, 55000))\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Gaussian-smoothed current\n",
+    "One way we can improve the signal-to-noise ratio is by applying a gaussian filter. This is a convolution of our waveform with a gaussian kernel. While this reduces the impact of noise on our measurement, it also reduces the timing information in the rising edge, reducing the signal; as a result, we must find an optimal balance in our smoothing. Knoll chapter 17 section 4 describes gaussian shaping.\n",
+    "\n",
+    "The current amplitude (A) is the maximum of the smoothed current waveform."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "browser = WaveformBrowser(data_file, \"geds/raw\",\n",
+    "                          dsp_config=dsp_config,\n",
+    "                          waveforms=[\"curr\", \"curr_gaus\"],\n",
+    "                          wf_styles=[{'color':['skyblue'], \"ls\":[':']}, {'color':['r']}],\n",
+    "                          lines=[\"A_gaus\"],\n",
+    "                          x_lim=(40000, 55000))\n",
+    "browser.draw_entry(entry_no)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Conclusion\n",
+    "This notebook has hopefully provided an informative introduction to some of the signal processing filters used by LEGEND to perform our analysis. The filters shown often have tradeoffs between reducing the impact of noise and preserving the information in the original waveform; for this reason, we typically optimize the input parameters of each processor. In addition, the parameters measured here require calibration to be physically meaningful. The slides in https://indico.legend-exp.org/event/699/ will introduce how each of these processes are done.\n",
+    "\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "name": "python"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/tutorials/LH5ParGridSearch.ipynb b/tutorials/LH5ParGridSearch.ipynb
new file mode 100644
index 000000000..395fdc7b0
--- /dev/null
+++ b/tutorials/LH5ParGridSearch.ipynb
@@ -0,0 +1,1556 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "c83038ea-f785-4a30-b3e4-0f17380dba56",
+   "metadata": {},
+   "source": [
+    "# LH5, DataGroup, and DSP Optimization Tutorial\n",
+    "\n",
+    "Clint Wiseman, UW.  Presented at [LEGEND Software Tutorial, Nov. 2021](https://indico.legend-exp.org/event/561/)\n",
+    "\n",
+    "With Ge and SiPM detectors, we need to be able to **look at waveforms,and quickly find optimal DSP parameters to get the best energy resolution and pulse shape discrimination**.  \n",
+    "\n",
+    "Users should be able to run this notebook to manually search for \"best guess\" parameters. \n",
+    "You can select a group of files to analyze using the `fileDB.h5` and a pandas query for a particular set of files.  This notebook only needs the **raw LH5 files,** and requires the user just manually select the **1460 and 2615** keV peaks, using the onboard energy parameter from the Struck card. \n",
+    "\n",
+    "Here are a few things we want to do in this notebook:\n",
+    "\n",
+    "- Show an example of `DataGroup`, which is frequently used for detector test stand setups (CAGE, HADES, SURF, etc.)\n",
+    "- Give examples of **creating and reading LH5 files**, which are required by pygama's `ProcessingChain` DSP class.\n",
+    "- **Optimize pole-zero corrections** to flatten the waveforms in `[4250:5500], [4250:8000]`\n",
+    "- **Optimize energy trapezoid** integration and flat top times (including asymmetric trap) to get best energy resolution\n",
+    "- **Optimize DCR parameter** -- vary the windows used to maximize alpha/gamma separation\n",
+    "- TODO: Optimize energy with charge trapping correction (see notes at the end)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "d4a9c9b6-38c8-4584-b28c-8061280b187f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import os, sys, h5py, json, time\n",
+    "import pandas as pd\n",
+    "import numpy as np\n",
+    "from scipy.optimize import curve_fit\n",
+    "\n",
+    "# use this to get interactive plots at NERSC.  \n",
+    "# requires ipympl & jupyter-matplotlib extension for jupyterlab\n",
+    "# user may need to $pip install ipympl --update\n",
+    "%matplotlib widget\n",
+    "\n",
+    "import matplotlib.pyplot as plt\n",
+    "\n",
+    "import warnings\n",
+    "with warnings.catch_warnings():\n",
+    "    warnings.simplefilter(\"ignore\")\n",
+    "    from tqdm import tqdm\n",
+    "    tqdm.pandas() # suppress annoying FutureWarning\n",
+    "\n",
+    "import pygama.analysis.histograms as pgh\n",
+    "import pygama.analysis.peak_fitting as pgf\n",
+    "from pygama import DataGroup, lh5\n",
+    "from pygama.dsp.dsp_optimize import *\n",
+    "from pygama.dsp.WaveformBrowser import WaveformBrowser as wfb"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a60d0116-816d-4b31-b4aa-03274447913c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# << -- CONFIG -- >> # \n",
+    "\n",
+    "# set query\n",
+    "# query = \"run == 280\" # \"2185-2212\", \"alp\", \"elog 364. overnight alpha run\"\n",
+    "query = 'cycle > 2185 and cycle < 2188'\n",
+    "\n",
+    "# set raw energy estimator\n",
+    "etype = 'energy'\n",
+    "tb_in = 'ORSIS3302DecoderForEnergy/raw'\n",
+    "wf_in = 'ORSIS3302DecoderForEnergy/raw/waveform'\n",
+    "xlo, xhi, xpb = 0, 4e6, 10000\n",
+    "\n",
+    "# set rough calibration: user fills this in from the first plot, below\n",
+    "peaks = {\n",
+    "    '40K':  [1460.8, 1.71e6],\n",
+    "    '208TL':[2614.5, 3.06e6]\n",
+    "}\n",
+    "\n",
+    "# set up a WaveformBrowser for just one of the peaks (user-selected)\n",
+    "pk_select = '208TL'\n",
+    "# pk_select = '40K'"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "af72faf8-50a8-479e-b657-4dd4c3d945fa",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# load data from the raw files.\n",
+    "# we need cycle number and packet index information to retreive waveforms, so make a dataframe\n",
+    "\n",
+    "dg = DataGroup('./metadata/cage.json', load=True)\n",
+    "dg.fileDB.query(query, inplace=True)\n",
+    "\n",
+    "if len(dg.fileDB)==0:\n",
+    "    print('Error, no files found.  Check your query, and fileDB.h5.')\n",
+    "    dg.fileDB[['runtype', 'run', 'cycle']]\n",
+    "\n",
+    "def load_file(row):\n",
+    "    data_cols = [etype, 'ievt']\n",
+    "    raw_file = dg.lh5_dir + row['raw_path'] + '/' + row['raw_file']\n",
+    "    raw_cycle = row.cycle\n",
+    "    raw_data = pd.DataFrame(lh5.load_nda(raw_file, data_cols, tb_in, verbose=False))\n",
+    "    raw_data['cycle'] = row['cycle']\n",
+    "    return raw_data\n",
+    "        \n",
+    "result = dg.fileDB.progress_apply(load_file, axis=1)\n",
+    "df_data = pd.concat([r for r in result])\n",
+    "df_mem = round(sys.getsizeof(df_data) / 1024 / 1024, 2)\n",
+    "\n",
+    "print('Found unique cycles:', df_data['cycle'].unique())\n",
+    "print(f\"Entries found: {df_data.shape}\")\n",
+    "print('In-memory size:', df_mem, \"MB\")\n",
+    "\n",
+    "raw_files = dg.lh5_dir + dg.fileDB['raw_path'] + '/' + dg.fileDB['raw_file']\n",
+    "\n",
+    "with h5py.File(raw_files.iloc[0], 'r') as hf:\n",
+    "    print('LH5 columns found :', list(hf[f'{tb_in}'].keys()))\n",
+    "    \n",
+    "df_data"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "0369cbab-05b1-4c4d-8570-4a89e572dabb",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# create raw energy histogram\n",
+    "%matplotlib widget\n",
+    "hist, bins, var = pgh.get_hist(df_data['energy'], range=(xlo, xhi), dx=xpb)\n",
+    "bins = bins[1:] # trim zero bin, not needed with ds='steps'\n",
+    "plt.semilogy(bins, hist, ds='steps', c='b', lw=1, label=etype)\n",
+    "plt.xlabel(etype)\n",
+    "plt.ylabel(f'cts, {xpb}/bin')\n",
+    "plt.show()\n",
+    "print('Hey, you need to zoom in and pick out the locations of the 1460 and 2615 peaks and write them into the config at the top!')"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b5d7dcb9-99f2-4186-ae47-3995a58cc05b",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "\n",
+    "# select events in peaks using a rough FWHM, optimized for onboard energy.\n",
+    "# this should work automatically if 'peaks' is correct.\n",
+    "# a lot of this is reused from CAGE energy_cal.py :: fit_peaks\n",
+    "\n",
+    "evts_pass = {}\n",
+    "\n",
+    "# adjustable parameters for auto-binning\n",
+    "win_multip = 30\n",
+    "nbin_gain = 4\n",
+    "nevt_max = 5000 # num. waveforms to retrieve from each peak\n",
+    "nsig_save = 4 # num. sig to save.  may need to be able to re-fit the whole peak\n",
+    "\n",
+    "fig, axs = plt.subplots(1, len(peaks), figsize=(len(peaks)*5, 4))\n",
+    "\n",
+    "for ipk, (pk, (cal_e, raw_e)) in enumerate(peaks.items()):\n",
+    "    \n",
+    "    # set the window.  assume resolution goes as roughly sqrt(energy).\n",
+    "    window = np.sqrt(raw_e) * win_multip\n",
+    "    plo, phi = raw_e - window / 2, raw_e + window / 2\n",
+    "    nbin_scale = nbin_gain * np.sqrt(raw_e) / raw_e\n",
+    "    nbins = int(window) * nbin_scale\n",
+    "    ppb = (phi - plo) / nbins\n",
+    "    \n",
+    "    h, b, var = pgh.get_hist(df_data[etype], range=(plo, phi), dx=ppb)\n",
+    "    b = b[1:]\n",
+    "    \n",
+    "    # get fwhm and select events in the window (ix_evts).  don't need to fit here.\n",
+    "    imax = np.argmax(h)\n",
+    "    ix_upr = np.where((b > b[imax]) & (h <= np.amax(h)/2))\n",
+    "    ix_bot = np.where((b < b[imax]) & (h <= np.amax(h)/2))\n",
+    "    \n",
+    "    upr_half = b[ix_upr][0]\n",
+    "    bot_half = b[ix_bot][-1]\n",
+    "    fwhm0 = upr_half - bot_half\n",
+    "    sig0 = fwhm0 / 2.355\n",
+    "\n",
+    "    raw_ctr = b[imax]\n",
+    "    raw_lo = raw_ctr - sig0 * nsig_save\n",
+    "    raw_hi = raw_ctr + sig0 * nsig_save\n",
+    "\n",
+    "    # select 'nevt_max' events to save waveforms, but sample evenly throughout the dataset\n",
+    "    df_pass = df_data.loc[(df_data[etype] > raw_lo) & (df_data[etype] < raw_hi)]\n",
+    "    n_evts = len(df_pass)\n",
+    "    n_select = nevt_max if n_evts > nevt_max else n_evts\n",
+    "    evts_pass[pk] = df_pass.sample(n_select).sort_values(['cycle','ievt'])\n",
+    "    # print(evts_pass[pk])\n",
+    "    \n",
+    "    axs[ipk].plot(b, h, ds='steps', c='b', lw=1, label=pk + ', ' + etype)\n",
+    "    axs[ipk].axvline(raw_lo, c='r', alpha=0.3, label=f'{nsig_save}-sig window\\n{n_evts} cts')\n",
+    "    axs[ipk].axvline(raw_hi, c='r', alpha=0.3)\n",
+    "    axs[ipk].legend(fontsize=8, loc=2)\n",
+    "    axs[ipk].set_xlabel(etype)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6dfe4299-ebb9-461e-ae56-a455a1dcbd55",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# save waveforms for events in peaks into memory as an LH5 table (and to disk).\n",
+    "# this can take a long time, which is why it's nice to only run once.\n",
+    "\n",
+    "sto = lh5.Store()\n",
+    "\n",
+    "tb_wfs = {} # LH5 tables for each peak\n",
+    "\n",
+    "# write the data to a temporary LH5 file usable by WaveformBrowser\n",
+    "f_wfs = './metadata/wfs_optimizer.lh5' \n",
+    "if os.path.exists(f_wfs):\n",
+    "    os.remove(f_wfs) # write_object :: append=False can't create a new file\n",
+    "\n",
+    "for pk, df_evts in evts_pass.items():\n",
+    "    print(f'Loading {pk} peak data ...')\n",
+    "    \n",
+    "    raw_files = dg.lh5_dir + dg.fileDB['raw_path'] + '/' + dg.fileDB['raw_file']\n",
+    "    raw_files = list(raw_files)\n",
+    "    \n",
+    "    df_idxs = df_evts.groupby('cycle').agg({'ievt' : lambda x: list(x)})\n",
+    "    raw_idxs = df_idxs['ievt'].values\n",
+    "    raw_idxs = list(raw_idxs)\n",
+    "    \n",
+    "    # this step can take a while, so time it\n",
+    "    t_start = time.time()\n",
+    "    tb_wfs[pk], n_wfs = sto.read_object(tb_in, raw_files, idx=raw_idxs)\n",
+    "    t_elap = (time.time() - t_start) / 60\n",
+    "    print(tb_wfs[pk]['waveform']['values'].nda.shape)\n",
+    "    print(f'    Waveforms loaded.  Elapsed time: {t_elap:.2f} min.')\n",
+    "    \n",
+    "    # some handy debug statements looking at h5py dset attrs, etc\n",
+    "    # tb_wfs[pk].attrs['datatype'] = 'table{t0, dt, values}' # required by WaveformBrowser\n",
+    "    # print(dir(tb_wfs[pk]))\n",
+    "    # print(tb_wfs.keys())\n",
+    "    # tb_raw = lh5.Table(col_dict={'waveform':tb_wfs[pk]})\n",
+    "    # tb_raw.attrs['datatype'] = 'table{waveform}'\n",
+    "    \n",
+    "    sto.write_object(tb_wfs[pk], f'{tb_in}/{pk}', f_wfs, append=True)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f5b3149e-0a44-4076-ac0f-c646be693ecd",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# For our output file to work with WaveformBrowser, the LH5 Tables, and their\n",
+    "# HDF5 attrs etc, all need to be the same.  To make sure this is the case,\n",
+    "# compare against the first raw file in our list, which we know is readable\n",
+    "# by WaveformBrowser.\n",
+    "\n",
+    "with h5py.File(f_wfs, 'r') as hf:\n",
+    "    print('LH5 columns found :', list(hf[f'{tb_in}/{pk_select}'].keys()))\n",
+    "    \n",
+    "def print_attrs(name, obj):\n",
+    "    print(name)\n",
+    "    for key, val in obj.attrs.items():\n",
+    "        print(\"    %s: %s\" % (key, val))\n",
+    "        \n",
+    "# debug -- this is useful for looking at LH5 dataset & attributes\n",
+    "        \n",
+    "# f_input = raw_files[0]\n",
+    "# with h5py.File(f_input, 'r') as hf:\n",
+    "#     print('LH5 columns found :', list(hf[f'{tb_in}'].keys()))\n",
+    "#     hf.visititems(print_attrs)\n",
+    "\n",
+    "print('\\nnew wfs file:')\n",
+    "f_input = f_wfs\n",
+    "with h5py.File(f_input, 'r') as hf:\n",
+    "    print('LH5 columns found :', list(hf[f'{tb_in}/{pk_select}'].keys()))\n",
+    "    hf.visititems(print_attrs)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "aa1ee942-d170-40f8-a797-3a132c8734a6",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "\n",
+    "# show some example waveforms from all peaks before running WaveformBrowser and ParGrid.\n",
+    "# limit to the rising edge because it's more interesting.\n",
+    "\n",
+    "n_max = 100\n",
+    "\n",
+    "fig, axs = plt.subplots(1, len(peaks), figsize=(len(peaks)*5, 4))\n",
+    "\n",
+    "for ipk, (pk, (cal_e, raw_e)) in enumerate(peaks.items()):\n",
+    "    \n",
+    "    n_wfs, nsamp = tb_wfs[pk]['waveform']['values'].nda.shape\n",
+    "    n_lim = n_max if n_wfs > n_max else n_wfs\n",
+    "    wfs = tb_wfs[pk]['waveform']['values'].nda[:n_lim,:]\n",
+    "    \n",
+    "    wf_ctr = nsamp / 2\n",
+    "    win_lo, win_hi = int(wf_ctr - 200), int(wf_ctr + 200)\n",
+    "    \n",
+    "    ts = np.arange(0, len(wfs[0, win_lo:win_hi]))\n",
+    "    for iwf in range(wfs.shape[0]):\n",
+    "        axs[ipk].plot(ts, wfs[iwf,win_lo:win_hi], lw=2, alpha=0.5)\n",
+    "    axs[ipk].plot(np.nan, np.nan, label = f'{pk}, {n_lim} wfs')\n",
+    "    axs[ipk].legend(loc=4, fontsize=10)\n",
+    "\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "df17c965-9d23-4cf5-8785-aad9947b88b3",
+   "metadata": {},
+   "source": [
+    "### 1. Optimize Pole-Zero Correction ('whole-tail')\n",
+    "\n",
+    "Here, I want to select values for the pole-zero correction (using the `double_pole_zero` calculator) that give the whole tail after the rising edge as flat of a slope as possible.  The tasks here are:\n",
+    "\n",
+    "- Set up a minimal DSP config file \n",
+    "- Declare a `WaveformBrowser` example to show the pole-zero corrected waveform, so that users can draw various wfs\n",
+    "- Set up a `ParGrid` class and call `run_one_dsp` for ONE set of parameters, to look at how the **figure of merit** works.\n",
+    "- Then in `optimizer_v6_grid.py`, we call the same FOM and run on a larger grid search.\n",
+    "\n",
+    "**NOTE:** We really don't need very many waveforms to get the pole-zero correction correct (almost all the tails have the same decay constant).  So we can limit this to like 10-100 waveforms and the `ParGrid` search will run much faster."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7bbc7f7b-8ad9-4ab7-ba4f-7ddeb5d95455",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# declare the DSP processor list.  later cells will augment these config parameter dict's.\n",
+    "\n",
+    "dsp_config = {\n",
+    "    \"outputs\" : [\"bl\", \"bl_sig\", \"pztail_mean\", \"pztail_sig\"],\n",
+    "    \"processors\" : {\n",
+    "         \"bl , bl_sig, slope, intercept\":{\n",
+    "            \"function\": \"linear_slope_fit\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\" : [\"waveform[:1650]\", \"bl\",\"bl_sig\", \"slope\",\"intercept\"],\n",
+    "            \"unit\": [\"ADC\",\"ADC\",\"ADC\",\"ADC\"]\n",
+    "        },\n",
+    "        \"wf_blsub\":{\n",
+    "            \"function\": \"subtract\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"waveform\", \"bl\", \"wf_blsub\"],\n",
+    "            \"prereqs\": [\"waveform\", \"bl\"],\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"wf_pz\": {\n",
+    "            \"function\": \"double_pole_zero\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"prereqs\": [\"wf_blsub\"],\n",
+    "            \"args\": [\"wf_blsub\", \"db.pz2.tau1\", \"db.pz2.tau2\",  \"db.pz2.frac\", \"wf_pz\"],\n",
+    "            \"defaults\": {\"db.pz2.tau1\":\"187.5*us\", \"db.pz2.tau2\":\"3.17*us\", \"db.pz2.frac\":\"0.035\" },\n",
+    "            \"unit\" : \"ADC\"\n",
+    "        },\n",
+    "         \"pztail_mean , pztail_sig, pz_slope, pz_intercept\":{\n",
+    "            \"function\": \"linear_slope_fit\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\" : [\"wf_pz[4250:8000]\", \"pztail_mean\",\"pztail_sig\", \"pz_slope\",\"pz_intercept\"],\n",
+    "            \"unit\": [\"ADC\",\"ADC\",\"ADC\",\"ADC\"]\n",
+    "        },\n",
+    "    }\n",
+    "}\n",
+    "\n",
+    "# try writing to a temporary file\n",
+    "# f_dsp = './dsp_tmp.json'\n",
+    "# with open(f_dsp, 'w') as f:\n",
+    "#     json.dump(dsp_config, f)\n",
+    "\n",
+    "# user should MANUALLY edit the parameters of interest to get an idea\n",
+    "# of which range we should pick to optimize with ParGrid.\n",
+    "# this DB will be updated with the 'optimized' parameter choices\n",
+    "# (consider this as an output of this notebook).\n",
+    "# note: description of the formatting is in pygama.dsp.build_processing_chain\n",
+    "dsp_db = {\n",
+    "    \"pz2\" : {\n",
+    "        \"tau1\" : \"51*us\",\n",
+    "        \"tau2\" : \"2*us\",\n",
+    "        \"frac\" : 0.04\n",
+    "    }\n",
+    "}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "df58fba5-ee5d-499b-96df-e422602576d0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "## -- waveform browser step -- \n",
+    "\n",
+    "# load our skim waveforms file\n",
+    "f_input = f_wfs \n",
+    "tb_input = f'{tb_in}/{pk_select}'\n",
+    "\n",
+    "# debug - load wf browser with raw file 0 & preexisting config\n",
+    "# dsp_config = os.path.expandvars(f'$CAGE_SW/processing/metadata/dsp/dsp_06.json')\n",
+    "# f_input = raw_files[0] # testing, has correct datatypes\n",
+    "# tb_input = f'{tb_in}'\n",
+    "\n",
+    "pprint(dsp_db)\n",
+    "\n",
+    "b = wfb(f_input, tb_input, dsp_config,\n",
+    "        waveforms=['wf_blsub', 'wf_pz'],\n",
+    "        database=dsp_db,\n",
+    "        # selection = cut,\n",
+    "        wf_styles=[{'linestyle':['-']}, {'linestyle':[':']}],\n",
+    "        #legend=['wf_blsub {bl:.2f}', 'pz-corrected'], # displaying the bl value is tricky\n",
+    "        legend_opts={'loc':\"lower right\"},\n",
+    "        #lines=['bl'],\n",
+    "        x_lim=(38000, 80000)\n",
+    "       )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "c9903aad-7dce-4891-b890-0918a657eb9d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "b.draw_next()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "bf2cf356-90aa-4d29-a65d-d3596b0023f1",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "\n",
+    "# -- EXAMPLE -- run_one_dsp and evaluate a figure of merit.\n",
+    "# This is automated by the ParGrid class, but it's good to be able to visualize & make plots of what's happening.\n",
+    "\n",
+    "col_name = 'pztail_sig'\n",
+    "\n",
+    "def mean_val(tb, verbosity):\n",
+    "    return np.average(tb[col_name].nda)\n",
+    "\n",
+    "# limit the number of wfs, we only need ~10--50 to get the pz correction right.  \n",
+    "# it's easier to reload from the file than to try and slice the in-memory `tb_wfs[pk_select]`.\n",
+    "nwfs_lim = 10\n",
+    "sto = lh5.Store()\n",
+    "tb_wfs_slim, n_wfs = sto.read_object(f'{tb_in}/{pk_select}', f_wfs, n_rows=nwfs_lim)\n",
+    "\n",
+    "tb_out = run_one_dsp(tb_wfs_slim, dsp_config, db_dict=dsp_db, verbosity=1, )\n",
+    "\n",
+    "mean = mean_val(tb_out, 0)\n",
+    "xlo, xhi, xpb = mean - mean/2, mean + mean/2, 0.5\n",
+    "h, b, var = pgh.get_hist(tb_out[col_name].nda, range=(xlo, xhi), dx=xpb)\n",
+    "plt.plot(b[1:], h, ds='steps', c='b')\n",
+    "plt.xlabel(col_name)\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "f5d7e790-0f0d-434b-a129-29a24b88d1e2",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# ParGrid setup\n",
+    "# internally this calls run_one_dsp with our figure of merit function.\n",
+    "# In the notebook we don't want to run on too many points,\n",
+    "# We can run a really FINE grid search in `optimizer_v6_grid.py` on the batch system.\n",
+    "\n",
+    "pg = ParGrid()\n",
+    "\n",
+    "# vary tau1, tau2, and frac\n",
+    "tau1_arr = np.linspace(51, 52, 6)\n",
+    "tau2_arr = np.linspace(5.5, 7, 6)\n",
+    "frac_arr = np.linspace(0.03, 0.05, 4)\n",
+    "\n",
+    "pg.add_dimension('wf_pz', 1, [f\"{t:.2f}*us\" for t in tau1_arr])\n",
+    "pg.add_dimension('wf_pz', 2, [f\"{t:.2f}*us\" for t in tau2_arr])\n",
+    "pg.add_dimension('wf_pz', 3, [f\"{t:.3f}\" for t in frac_arr])\n",
+    "\n",
+    "print('tau1:', tau1_arr)\n",
+    "print('tau2:', tau2_arr)\n",
+    "print('frac:', frac_arr)\n",
+    "ngrid = pg.get_n_grid_points()\n",
+    "print('grid points to search:', ngrid)\n",
+    "\n",
+    "# the more waveforms we have, the longer it will take to run one grid point\n",
+    "nwfs = tb_wfs_slim['waveform']['values'].nda.shape[0]\n",
+    "print('wfs to reprocess:', nwfs * ngrid)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "95dc6766-ff70-4a81-8566-ca5cb95928bf",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# run the grid search.\n",
+    "\n",
+    "# NOTE: the fom_function does NOT support additional function arguments.\n",
+    "fom_vals = run_grid(tb_wfs_slim, dsp_config, pg, mean_val, verbosity=0)\n",
+    "\n",
+    "# unpack the results into a DataFrame.  \n",
+    "# have to iterate thru the n-dimensional grid\n",
+    "grid_nd = []\n",
+    "ix = pg.get_zero_indices()\n",
+    "while True:\n",
+    "    row = []\n",
+    "    for i_dim, i_par in enumerate(ix):\n",
+    "        name, i_arg, value_str, _ = pg.get_data(i_dim, i_par)\n",
+    "        #if '*' in value_str:\n",
+    "        #    val = float(value_str.split('*')[0])\n",
+    "        #else:\n",
+    "        #    val = float(value_str)\n",
+    "        row.append(value_str)\n",
+    "    grid_nd.append(row)\n",
+    "    if not pg.iterate_indices(ix): break\n",
+    "\n",
+    "df_grid = pd.DataFrame(grid_nd, columns=['tau1','tau2','frac'])\n",
+    "\n",
+    "results_1d = fom_vals.reshape(-1, pg.get_n_grid_points())\n",
+    "df_grid['fom'] = results_1d[0]\n",
+    "\n",
+    "print(\"NOTE: if one of the best settings is at the upper/lower limit of your parameter grid,\",\n",
+    "      \"\\nyou probably need to adjust the grid to find the true min.\")\n",
+    "\n",
+    "# df_grid # show full df\n",
+    "df_best = df_grid.sort_values('fom')[:5] # show 5 best settings\n",
+    "df_best"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "3d41cf48-665a-4d93-af20-824be529f22a",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# finally, update the dsp_db with the best result\n",
+    "\n",
+    "dbest = df_best.iloc[0].to_dict()\n",
+    "\n",
+    "for par, val in dbest.items():\n",
+    "    if par == 'fom': continue\n",
+    "    dsp_db['pz2'][par] = val\n",
+    "\n",
+    "print(\"NOTE: you can go back and re-run the WaveformBrowser step now,\",\n",
+    "     \"\\nto see the effect of the updated PZ values.\")\n",
+    "dsp_db"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6136d220-5240-4216-9fb7-6dfa0c219ff9",
+   "metadata": {},
+   "source": [
+    "## 2. Energy Trapezoid Optimization\n",
+    "\n",
+    "Our main energy estimator is `trapEftp`, which uses a combination of different trapezoid settings.  The most important ones to get right are in the  \"energy trapezoid\" `wf_trap` -- the trap filter with a long integration time.  This is used for `trapEmax`.  The fixed-time-pickoff calculation also uses an asymmetric trapezoid to find `t0`, and then we set a \"pickoff time\".  Jason and Ian have said that as long as the pickoff time selects a point on the flat top of the energy trapezoid, its value is rather arbitrary.  Similarly, the `t0` calculator is fairly robust too, and these parameters shouldn't need to be varied as much as the energy trap parameters.\n",
+    "\n",
+    "So here, we're going to optimize `db.etrap.rise`, `db.etrap.flat`, and for fun we can also optimize `db.pz2.tau1` to see if we get a different value than what we got above.  I bet we will ..."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "1cd6dea8-3fbe-442d-beab-525a787bfe01",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# re-declare dsp_config to contain energy trapezoid calculators.\n",
+    "# this is visually simpler than trying to augment the existing dictionary.\n",
+    "# however, we should update the dsp_db dict, since it's the \"output\" of this notebook.\n",
+    "\n",
+    "dsp_config = {\n",
+    "    \"outputs\" : [\"tp_0\", \"trapEmax\", \"atrap_max\", \"trapEftp\", \"tp_ftp\"],\n",
+    "    \"processors\" : {\n",
+    "         \"bl , bl_sig, slope, intercept\":{\n",
+    "            \"function\": \"linear_slope_fit\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\" : [\"waveform[:1650]\", \"bl\",\"bl_sig\", \"slope\",\"intercept\"],\n",
+    "            \"unit\": [\"ADC\",\"ADC\",\"ADC\",\"ADC\"]\n",
+    "        },\n",
+    "        \"wf_blsub\":{\n",
+    "            \"function\": \"subtract\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"waveform\", \"bl\", \"wf_blsub\"],\n",
+    "            \"prereqs\": [\"waveform\", \"bl\"],\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"wf_pz\": {\n",
+    "            \"function\": \"double_pole_zero\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"prereqs\": [\"wf_blsub\"],\n",
+    "            \"args\": [\"wf_blsub\", \"db.pz2.tau1\", \"db.pz2.tau2\",  \"db.pz2.frac\", \"wf_pz\"],\n",
+    "            \"defaults\": {\"db.pz2.tau1\":\"187.5*us\", \"db.pz2.tau2\":\"3.17*us\", \"db.pz2.frac\":\"0.035\" },\n",
+    "            \"unit\" : \"ADC\"\n",
+    "        },\n",
+    "        \"wf_etrap\": {\n",
+    "            \"function\": \"trap_norm\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"prereqs\": [\"wf_pz\"],\n",
+    "            \"args\": [\"wf_pz\", \"db.etrap.rise\", \"db.etrap.flat\", \"wf_etrap\"],\n",
+    "            \"defaults\" : {\"db.etrap.rise\":\"4*us\", \"db.etrap.flat\":\"1*us\"},\n",
+    "            \"unit\": \"ADC\"\n",
+    "        },\n",
+    "        \"wf_atrap\": {\n",
+    "            \"function\": \"asym_trap_filter\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"prereqs\": [\"wf_pz\"],\n",
+    "            # \"args\": [\"wf_pz\", \"round(0.1*us)\", \"round(1*us)\", \"round(4*us)\", \"wf_atrap\"], # ian's\n",
+    "            \"args\": [\"wf_pz\", \"db.atrap.rise\", \"db.atrap.flat\", \"db.atrap.fall\", \"wf_atrap\"], # clint's\n",
+    "            \"defaults\" : {\"db.atrap.rise\":\"20*ns\", \"db.atrap.flat\":\"1*us\",\"db.atrap.fall\":\"4*us\"},\n",
+    "            \"unit\": \"ADC\"\n",
+    "        },\n",
+    "        \"trapEmax\": {\n",
+    "            \"function\": \"amax\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"wf_etrap\", 1, \"trapEmax\"],\n",
+    "            \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->f\"]},\n",
+    "            \"unit\": \"ADC\",\n",
+    "            \"prereqs\": [\"wf_etrap\"]\n",
+    "        },\n",
+    "         \"atrap_max\": {\n",
+    "             \"function\": \"argmax\",\n",
+    "              \"module\": \"numpy\",\n",
+    "              \"args\": [\"wf_atrap\", 1, \"atrap_max\"],\n",
+    "              \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
+    "              \"unit\": \"ADC\",\n",
+    "              \"prereqs\": [\"wf_atrap\"]\n",
+    "        },\n",
+    "        \"tmax\": {\n",
+    "            \"function\": \"argmax\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"wf_atrap\", 1, \"tmax\"],\n",
+    "            \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
+    "            \"unit\": \"ns\"\n",
+    "        },\n",
+    "        \"tp_0\": {\n",
+    "            \"function\": \"time_point_thresh\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_atrap\", 0, \"tmax\", 0, \"tp_0\"],\n",
+    "            \"unit\": \"ns\",\n",
+    "        },\n",
+    "         \"trapEftp\": {\n",
+    "            \"function\": \"fixed_time_pickoff\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_etrap\", \"db.tp_ftp.ftp\", \"trapEftp\"],\n",
+    "            \"defaults\" : {\"db.tp_ftp.ftp\":\"tp_0 + 5.5*us\"},\n",
+    "            \"unit\": \"ADC\",\n",
+    "            \"prereqs\": [\"wf_etrap\", \"tp_0\"]\n",
+    "        },\n",
+    "        \"tp_ftp\" : {\n",
+    "            \"function\":\"add\",\n",
+    "            \"module\":\"numpy\",\n",
+    "            \"args\":[\"tp_0\", \"db.tp_ftp.ftp\", \"tp_ftp\"],\n",
+    "            \"defaults\" : {\"db.tp_ftp.ftp\":\"tp_0 + 5.5*us\"},\n",
+    "            \"prereqs\":[\"tp_0\"],\n",
+    "            \"unit\":\"ns\"\n",
+    "        }\n",
+    "    }\n",
+    "}\n",
+    "\n",
+    "# add parameters to dsp_db\n",
+    "dsp_db['etrap'] = {\"rise\":\"4*us\", \"flat\":\"2*us\"}\n",
+    "dsp_db['atrap'] = {\"rise\":\"20*ns\", \"flat\":\"1*us\", \"fall\":\"4*us\"}\n",
+    "dsp_db['tp_ftp'] = {\"ftp\":\"tp_0 + 5.5*us\"} # << this gets trapEftp correct!!\n",
+    "# dsp_db['tp_ftp'] = {\"ftp\":\"5.5*us\"} # << this one draws correctly on the plot !! weird.\n",
+    "\n",
+    "# pprint(dsp_db)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a5581961-15a0-46aa-b71d-47e761677064",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "## -- waveform browser step -- \n",
+    "# user should MANUALLY edit the parameters of interest to get an idea\n",
+    "# of which ranges & parameters we should pick to optimize with ParGrid\n",
+    "\n",
+    "# load our skim waveforms file\n",
+    "f_input = f_wfs \n",
+    "tb_input = f'{tb_in}/{pk_select}'\n",
+    "\n",
+    "print('DB input parameters:')\n",
+    "pprint(dsp_db)\n",
+    "\n",
+    "b = wfb(f_input, tb_input, dsp_config,\n",
+    "        waveforms=['wf_blsub', 'wf_pz', 'wf_etrap', 'wf_atrap'],\n",
+    "        database=dsp_db,\n",
+    "        legend=['wf_blsub', 'wf_pz', 'wf_etrap', 'wf_atrap'],\n",
+    "        # lines=['trapEftp', 'tp_ftp', 'tp_0'], # hmm, I can't get any of these to work, need to ask ian\n",
+    "        x_lim=(38000, 55000)\n",
+    "       )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "67532b68-f43e-412c-be81-bdd074238ad9",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "b.draw_next()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "3f71fe9a-aac0-4691-b029-e4b1f23c13f5",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "\n",
+    "# -- EXAMPLE: try a couple different figures of merit to optimize the\n",
+    "# height/width of our test peak.\n",
+    "\n",
+    "# ene_type = 'trapEftp' # this one was giving me issues\n",
+    "ene_type = 'trapEmax' # <-- need to use this one\n",
+    "\n",
+    "win_multip = 0.5\n",
+    "nbin_gain = 100\n",
+    "\n",
+    "def peak_height(tb, verbosity):\n",
+    "    raw_e = np.mean(tb[ene_type].nda)\n",
+    "    window = np.sqrt(raw_e) * win_multip\n",
+    "    plo, phi = raw_e - window / 2, raw_e + window / 2\n",
+    "    nbin_scale = nbin_gain * np.sqrt(raw_e) / raw_e\n",
+    "    nbins = int(window) * nbin_scale\n",
+    "    ppb = (phi - plo) / nbins\n",
+    "    h, b, var = pgh.get_hist(tb[ene_type].nda, range=(plo, phi), dx=ppb)\n",
+    "    # plt.plot(b[1:], h, ds='steps')\n",
+    "    # plt.show()\n",
+    "    return np.max(h)\n",
+    "\n",
+    "\n",
+    "def peak_width(tb, verbosity, make_plot=False):\n",
+    "    \n",
+    "    # histogram the data\n",
+    "    raw_e = np.mean(tb[ene_type].nda)\n",
+    "    window = np.sqrt(raw_e) * win_multip\n",
+    "    plo, phi = raw_e - window / 2, raw_e + window / 2\n",
+    "    nbin_scale = nbin_gain * np.sqrt(raw_e) / raw_e\n",
+    "    nbins = int(window) * nbin_scale\n",
+    "    ppb = (phi - plo) / nbins\n",
+    "    h, bins, var = pgh.get_hist(tb[ene_type].nda, range=(plo, phi), dx=ppb)\n",
+    "    b = bins[1:]\n",
+    "\n",
+    "    # get initial guesses for simple Gauss fit\n",
+    "    imax = np.argmax(h)\n",
+    "    ix_upr = np.where((b > b[imax]) & (h <= np.amax(h)/2))\n",
+    "    ix_bot = np.where((b < b[imax]) & (h <= np.amax(h)/2))\n",
+    "    upr_half = b[ix_upr][0]\n",
+    "    bot_half = b[ix_bot][-1]\n",
+    "    fwhm0 = upr_half - bot_half\n",
+    "    sig0 = fwhm0 / 2.355\n",
+    "    amp0 = np.amax(h) * fwhm0\n",
+    "    p_init = [b[imax], sig0, amp0]\n",
+    "\n",
+    "    # run curve_fit through pygama's wrapper function\n",
+    "    fit_func = pgf.gauss\n",
+    "    p_fit, p_cov = pgf.fit_hist(fit_func, h, bins,\n",
+    "                                var=var, guess=p_init)\n",
+    "    p_err = np.sqrt(np.diag(p_cov))\n",
+    "\n",
+    "    if make_plot:\n",
+    "        plt.plot(b, h, ds='steps', c='b', lw=1, label=pk_select + ', ' + etype)\n",
+    "        xfit = np.arange(plo, phi, ppb * 0.1)\n",
+    "        plt.plot(xfit, fit_func(xfit, *p_init), '-', c='orange', label='init')\n",
+    "        plt.plot(xfit, fit_func(xfit, *p_fit), '-', c='red', label='fit')\n",
+    "        plt.legend(fontsize=8, loc=2)\n",
+    "        plt.xlabel(etype)\n",
+    "        plt.show()\n",
+    "\n",
+    "    return p_fit[1] * 2.355 # fwhm\n",
+    "\n",
+    "print('DB parameters:')\n",
+    "pprint(dsp_db)\n",
+    "\n",
+    "# run dsp -- for some reason it's not printing the db.etrap lookup, but seems to use it ...\n",
+    "tb_out = run_one_dsp(tb_wfs[pk_select], dsp_config, db_dict=dsp_db, verbosity=1)\n",
+    "\n",
+    "# this is how i examined the problem with tp_ftp and trapEftp \n",
+    "print(tb_out.keys())\n",
+    "df = tb_out.get_dataframe()\n",
+    "\n",
+    "# check figure of merit\n",
+    "height = peak_height(tb_out, 0)\n",
+    "print(\"counts in max bin:\", height)\n",
+    "\n",
+    "fwhm = peak_width(tb_out, 0, True)\n",
+    "print(\"fwhm:\", fwhm)\n",
+    "\n",
+    "df"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "edf6db16-db5c-468d-86a0-7bc6bad729a7",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# ParGrid setup\n",
+    "# internally this calls run_one_dsp with our figure of merit function.\n",
+    "# In the notebook we don't want to run on too many points,\n",
+    "# We can run a really FINE grid search in `optimizer_v6_grid.py` on the batch system.\n",
+    "\n",
+    "pg = ParGrid()\n",
+    "\n",
+    "# vary rise, flat, and pz\n",
+    "rise_arr = np.linspace(8, 11, 9)\n",
+    "flat_arr = np.linspace(2, 4, 3)\n",
+    "# pz_arr = np.linspace(51, 51, 1)\n",
+    "\n",
+    "pg.add_dimension('wf_etrap', 1, [f\"{t:.2f}*us\" for t in rise_arr])\n",
+    "pg.add_dimension('wf_etrap', 2, [f\"{t:.2f}*us\" for t in flat_arr])\n",
+    "# pg.add_dimension('wf_pz', 3, [f\"{t:.2f}*us\" for t in pz_arr])\n",
+    "\n",
+    "print('rise:', rise_arr)\n",
+    "print('flat:', flat_arr)\n",
+    "# print('pz:', pz_arr)\n",
+    "ngrid = pg.get_n_grid_points()\n",
+    "print('grid points to search:', ngrid)\n",
+    "\n",
+    "# the more waveforms we have, the longer it will take to run one grid point\n",
+    "nwfs = tb_wfs[pk_select]['waveform']['values'].nda.shape[0]\n",
+    "print('wfs to reprocess:', nwfs * ngrid)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "74cc3c30-6a23-4f69-a306-237616d09403",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# run the grid search.\n",
+    "\n",
+    "# NOTE: the fom_function does NOT support additional function arguments.\n",
+    "fom_vals = run_grid(tb_wfs[pk_select], dsp_config, pg, peak_width, db_dict=dsp_db, verbosity=0)\n",
+    "\n",
+    "# unpack the results into a DataFrame.  \n",
+    "# have to iterate thru the n-dimensional grid\n",
+    "grid_nd = []\n",
+    "ix = pg.get_zero_indices()\n",
+    "while True:\n",
+    "    row = []\n",
+    "    for i_dim, i_par in enumerate(ix):\n",
+    "        name, i_arg, value_str, _ = pg.get_data(i_dim, i_par)\n",
+    "        #if '*' in value_str:\n",
+    "        #    val = float(value_str.split('*')[0])\n",
+    "        #else:\n",
+    "        #    val = float(value_str)\n",
+    "        row.append(value_str)\n",
+    "    grid_nd.append(row)\n",
+    "    if not pg.iterate_indices(ix): break\n",
+    "\n",
+    "df_grid = pd.DataFrame(grid_nd, columns=['rise','flat'])\n",
+    "\n",
+    "results_1d = fom_vals.reshape(-1, pg.get_n_grid_points())\n",
+    "df_grid['fom'] = results_1d[0]\n",
+    "\n",
+    "print(\"NOTE: if one of the best settings is at the upper/lower limit of your parameter grid,\",\n",
+    "      \"\\nyou probably need to adjust the grid to find the true min.\")\n",
+    "\n",
+    "# df_grid # show full df\n",
+    "df_best = df_grid.sort_values('fom')\n",
+    "df_best[:5]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "7d5dde95-5ed6-4d5c-a9b0-5fcd6e27aaa9",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# update the dsp_db with the best result.\n",
+    "print(df_best.iloc[0])\n",
+    "dbest = df_grid.sort_values('fom').iloc[0].to_dict()\n",
+    "\n",
+    "for par, val in dbest.items():\n",
+    "    if par == 'fom': continue\n",
+    "    dsp_db['etrap'][par] = val\n",
+    "    \n",
+    "# tp_ftp should be updated to tp_0 + rise + flat/2 automatically, or you're gonna forget ...\n",
+    "rt = float(dsp_db['etrap']['rise'].split('*')[0])\n",
+    "ft = float(dsp_db['etrap']['flat'].split('*')[0])\n",
+    "dsp_db['tp_ftp']['ftp'] = f'tp_0 + {rt+ft/2}*us'\n",
+    "\n",
+    "print(\"NOTE 1: you can go back and re-run the WaveformBrowser step now,\",\n",
+    "     \"\\nto see the effect of the updated values.\")\n",
+    "\n",
+    "dsp_db\n",
+    "\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "44727bf0-f557-4402-a555-0647f578225f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "\n",
+    "# run dsp with the best result and check resolution\n",
+    "\n",
+    "print('DB parameters:')\n",
+    "pprint(dsp_db)\n",
+    "# pprint(dsp_config)\n",
+    "\n",
+    "# run dsp\n",
+    "tb_out = run_one_dsp(tb_wfs[pk_select], dsp_config, db_dict=dsp_db, verbosity=1)\n",
+    "\n",
+    "# this is how i examined the problem with tp_ftp and trapEftp \n",
+    "print(tb_out.keys())\n",
+    "df = tb_out.get_dataframe()\n",
+    "\n",
+    "\n",
+    "# check figure of merit\n",
+    "height = peak_height(tb_out, 0)\n",
+    "print(\"counts in max bin:\", height)\n",
+    "\n",
+    "fwhm = peak_width(tb_out, 0, True)\n",
+    "print(\"fwhm:\", fwhm)\n",
+    "\n",
+    "df"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ed51e7d0-9110-45c9-900b-0e0f0b0d46ef",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# load our skim waveforms file\n",
+    "f_input = f_wfs \n",
+    "tb_input = f'{tb_in}/{pk_select}'\n",
+    "\n",
+    "print('DB input parameters:')\n",
+    "pprint(dsp_db)\n",
+    "\n",
+    "b = wfb(f_input, tb_input, dsp_config,\n",
+    "        waveforms=['wf_blsub', 'wf_pz', 'wf_etrap', 'wf_atrap'],\n",
+    "        database=dsp_db,\n",
+    "        legend=['wf_blsub', 'wf_pz', 'wf_etrap', 'wf_atrap'],\n",
+    "        lines=['tp_ftp', 'tp_0', 'trapEftp', 'tp_ftp'],\n",
+    "        x_lim=(38000, 80000)\n",
+    "       )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "241cbcb7-17fd-4bf8-afd2-7d2c57be7ccd",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "b.draw_next()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b2a9f0ca-d768-44a8-ab4f-b9551741a10d",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pprint(dsp_db)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "066effd8-7b9f-449e-86b5-a66c6e7ff6de",
+   "metadata": {},
+   "source": [
+    "## 3. DCR Parameter Optimization\n",
+    "\n",
+    "DCR is basically measuring \"the slope of the tail\" of each waveform.  We know that alpha events tend to have a higher slope, meaning that some excess charge is \"trickling in\" to the detector much more slowly than the bulk of the charge collection, which creates the rising edge of a signal.  To calculate it, instead of fitting the tail (which is slow), we just take the average in two ~1 us windows on the tail, and subtract them: `DCR = win2 - win1 / len(win)`\n",
+    "\n",
+    "In pygama this is equivalent to calculating a trapezoid filter with `rise = win1, win2` and `flat = (spacing between windows)` (typically 10--20 usec), and then evaluating the trapezoid at its first point (a fixed-time pickoff).\n",
+    "\n",
+    "In previous CAGE analyses, we've found that varying the window where we optimize the pole-zero correction (so, making another choice than `[4200:8000]` for the tail) can lead to better alpha/gamma event separation.  To optimize DCR, there are sort of two steps:\n",
+    "1. Take a small population of gamma events (so, our 40K or 208TL sample waveforms) and tune `dcr` such that it's as close to zero as possible.  Note there may be an overall trend with energy that you can't really get rid of.\n",
+    "2. Check the alpha-gamma separation in a 2D plot.  Access the first few files in the `raw_files` array to get enough statistics.  This can also take a few minutes to complete `run_one_dsp` so keeping the array sizes as small as possible is useful."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "24b65602-996f-4b20-8920-1972f5fda9f9",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dsp_config = {\n",
+    "    \"outputs\" : [\"tp_0\", \"trapEmax\", \"atrap_max\", \"trapEftp\", \"tp_ftp\", \"dcr\"],\n",
+    "    \"processors\" : {\n",
+    "         \"bl , bl_sig, slope, intercept\":{\n",
+    "            \"function\": \"linear_slope_fit\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\" : [\"waveform[:3500]\", \"bl\",\"bl_sig\", \"slope\",\"intercept\"],\n",
+    "            \"unit\": [\"ADC\",\"ADC\",\"ADC\",\"ADC\"]\n",
+    "        },\n",
+    "        \"wf_blsub\":{\n",
+    "            \"function\": \"subtract\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"waveform\", \"bl\", \"wf_blsub\"],\n",
+    "            \"prereqs\": [\"waveform\", \"bl\"],\n",
+    "            \"unit\": \"ADC\",\n",
+    "        },\n",
+    "        \"wf_pz\": {\n",
+    "            \"function\": \"double_pole_zero\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"prereqs\": [\"wf_blsub\"],\n",
+    "            \"args\": [\"wf_blsub\", \"db.pz2.tau1\", \"db.pz2.tau2\",  \"db.pz2.frac\", \"wf_pz\"],\n",
+    "            \"defaults\": {\"db.pz2.tau1\":\"187.5*us\", \"db.pz2.tau2\":\"3.17*us\", \"db.pz2.frac\":\"0.035\" },\n",
+    "            \"unit\" : \"ADC\"\n",
+    "        },\n",
+    "        \"wf_etrap\": {\n",
+    "            \"function\": \"trap_norm\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"prereqs\": [\"wf_pz\"],\n",
+    "            \"args\": [\"wf_pz\", \"db.etrap.rise\", \"db.etrap.flat\", \"wf_etrap\"],\n",
+    "            \"defaults\" : {\"db.etrap.rise\":\"4*us\", \"db.etrap.flat\":\"1*us\"},\n",
+    "            \"unit\": \"ADC\"\n",
+    "        },\n",
+    "\"wf_atrap\": {\n",
+    "            \"function\": \"asym_trap_filter\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"prereqs\": [\"wf_pz\"],\n",
+    "            # \"args\": [\"wf_pz\", \"round(0.1*us)\", \"round(1*us)\", \"round(4*us)\", \"wf_atrap\"], # ian's\n",
+    "            \"args\": [\"wf_pz\", \"db.atrap.rise\", \"db.atrap.flat\", \"db.atrap.fall\", \"wf_atrap\"], # clint's\n",
+    "            \"defaults\" : {\"db.atrap.rise\":\"20*ns\", \"db.atrap.flat\":\"1*us\",\"db.atrap.fall\":\"4*us\"},\n",
+    "            \"unit\": \"ADC\"\n",
+    "        },\n",
+    "        \"trapEmax\": {\n",
+    "            \"function\": \"amax\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"wf_etrap\", 1, \"trapEmax\"],\n",
+    "            \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->f\"]},\n",
+    "            \"unit\": \"ADC\",\n",
+    "            \"prereqs\": [\"wf_etrap\"]\n",
+    "        },\n",
+    "         \"atrap_max\": {\n",
+    "             \"function\": \"argmax\",\n",
+    "              \"module\": \"numpy\",\n",
+    "              \"args\": [\"wf_atrap\", 1, \"atrap_max\"],\n",
+    "              \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
+    "              \"unit\": \"ADC\",\n",
+    "              \"prereqs\": [\"wf_atrap\"]\n",
+    "        },\n",
+    "        \"tmax\": {\n",
+    "            \"function\": \"argmax\",\n",
+    "            \"module\": \"numpy\",\n",
+    "            \"args\": [\"wf_atrap\", 1, \"tmax\"],\n",
+    "            \"kwargs\": {\"signature\":\"(n),()->()\", \"types\":[\"fi->i\"]},\n",
+    "            \"unit\": \"ns\"\n",
+    "        },\n",
+    "        \"tp_0\": {\n",
+    "            \"function\": \"time_point_thresh\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_atrap\", 0, \"tmax\", 0, \"tp_0\"],\n",
+    "            \"unit\": \"ns\",\n",
+    "        },\n",
+    "         \"trapEftp\": {\n",
+    "            \"function\": \"fixed_time_pickoff\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_etrap\", \"db.tp_ftp.ftp\", \"trapEftp\"],\n",
+    "            \"defaults\" : {\"db.tp_ftp.ftp\":\"tp_0 + 5.5*us\"},\n",
+    "            \"unit\": \"ADC\",\n",
+    "            \"prereqs\": [\"wf_etrap\", \"tp_0\"]\n",
+    "        },\n",
+    "        \"tp_ftp\" : {\n",
+    "            \"function\":\"add\",\n",
+    "            \"module\":\"numpy\",\n",
+    "            \"args\":[\"tp_0\", \"db.tp_ftp.ftp\", \"tp_ftp\"],\n",
+    "            \"defaults\" : {\"db.tp_ftp.ftp\":\"tp_0 + 5.5*us\"},\n",
+    "            \"prereqs\":[\"tp_0\"],\n",
+    "            \"unit\":\"ns\"\n",
+    "        },\n",
+    "        \"wf_pz_dcr\": {\n",
+    "            \"function\": \"double_pole_zero\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"prereqs\": [\"wf_blsub\"],\n",
+    "            \"args\": [\"wf_blsub\", \"db.dcr_pz.tau1\", \"db.dcr_pz.tau2\",  \"db.dcr_pz.frac\", \"wf_pz_dcr\"],\n",
+    "            \"defaults\": {\"db.dcr_pz.tau1\":\"50*us\", \"db.dcr_pz.tau2\":\"6*us\", \"db.dcr_pz.frac\":\"0.04\" },\n",
+    "            \"unit\" : \"ADC\"\n",
+    "        },\n",
+    "        \"wf_dcr_trap\": {\n",
+    "            \"function\": \"trap_norm\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_pz_dcr\", \"db.dcr_trap.rise\", \"db.dcr_trap.flat\", \"wf_dcr_trap\"],\n",
+    "            \"defaults\" : {\"db.dcr_trap.rise\":\"7.5*us\", \"db.dcr_trap.flat\":\"22.5*us\"},\n",
+    "            \"unit\": \"ADC\",\n",
+    "            \"prereqs\": [\"wf_pz_dcr\"]\n",
+    "        },\n",
+    "        \"dcr\": {\n",
+    "            \"function\": \"fixed_time_pickoff\",\n",
+    "            \"module\": \"pygama.dsp.processors\",\n",
+    "            \"args\": [\"wf_dcr_trap\", \"db.dcr.ftp\", \"dcr\"],\n",
+    "            \"defaults\" : {\"db.dcr.ftp\" : \"79*us\"},\n",
+    "            \"unit\": \"ADC\",\n",
+    "            \"prereqs\": [\"wf_dcr_trap\"]\n",
+    "        }\n",
+    "    }\n",
+    "}\n",
+    "\n",
+    "# add parameters to dsp_db\n",
+    "# dsp_db['dcr_pz'] = {\"tau1\":\"52*us\", \"tau2\":\"6.*us\", \"frac\":\"0.04\"}\n",
+    "dsp_db['dcr_pz'] = dsp_db['pz2'] # set equal to best result from above\n",
+    "# dsp_db['dcr_trap'] = {\"rise\":\"7.5*us\", \"flat\":\"22.5*us\"}\n",
+    "dsp_db['dcr_trap'] = {'flat': '20.33*us', 'rise': '7.00*us'}\n",
+    "dsp_db['dcr'] = {\"ftp\":\"80*us\"}\n",
+    "\n",
+    "pprint(dsp_db)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "63852f53-c705-49ca-b5d6-bebc8b93f597",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# browse waveforms\n",
+    "\n",
+    "f_input = f_wfs \n",
+    "tb_input = f'{tb_in}/{pk_select}'\n",
+    "\n",
+    "print('DB input parameters:')\n",
+    "pprint(dsp_db)\n",
+    "\n",
+    "b = wfb(f_input, tb_input, dsp_config,\n",
+    "        waveforms=['wf_blsub', 'wf_pz_dcr', 'wf_dcr_trap'],\n",
+    "        database=dsp_db,\n",
+    "        # legend=['wf_blsub', 'wf_pz_dcr', 'wf_dcr_trap'],\n",
+    "        lines=['dcr'],\n",
+    "        x_lim=(38000, 80000)\n",
+    "       )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "33541201-eb61-4d3e-a695-61cc4175a962",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "b.draw_next()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "79e5c81b-9929-4f82-9b6d-a517a73af270",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "\n",
+    "# EXAMPLE -- run_one_dsp with this config file and check the output dataframe.\n",
+    "\n",
+    "def mean_val(tb, verbosity, make_plot=False):\n",
+    "    \n",
+    "    if make_plot:\n",
+    "        ps = pd.Series(tb['dcr'].nda)\n",
+    "        ps.hist()\n",
+    "    \n",
+    "    return np.average(tb[\"dcr\"].nda)\n",
+    "\n",
+    "print('DB parameters:')\n",
+    "pprint(dsp_db)\n",
+    "\n",
+    "# run dsp -- for some reason it's not printing the db.etrap lookup, but seems to use it ...\n",
+    "tb_out = run_one_dsp(tb_wfs[pk_select], dsp_config, db_dict=dsp_db, verbosity=1)\n",
+    "\n",
+    "print(tb_out.keys())\n",
+    "df = tb_out.get_dataframe()\n",
+    "\n",
+    "# check figure of merit\n",
+    "mean_dcr = mean_val(tb_out, 0, True)\n",
+    "print(\"mean:\", mean_dcr)\n",
+    "\n",
+    "df"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "76bbbbaa-6f51-4d62-9398-3e30843b0742",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# ParGrid setup\n",
+    "\n",
+    "\n",
+    "pg = ParGrid()\n",
+    "\n",
+    "# we can vary: dcr_pz.tau1, dcr_pz.tau2, dcr_trap.rise, dcr_trap.flat,\n",
+    "# but the most sensitive parameters should be tau1 and tau2.\n",
+    "# NOTE: we also may want to vary the waveform window, which can only be set in dsp_config\n",
+    "\n",
+    "# optimizing tau didn't really do that well, it introduced a lot of curvature.\n",
+    "# seems like it's better to use the best-fit pz corrected waveform found above,\n",
+    "# and then vary rise/flat to try to pin the dcr values of 2615 to 0 ...\n",
+    "# tau1_arr = np.linspace(50, 53, 10)\n",
+    "# tau2_arr = np.linspace(2, 4, 3)\n",
+    "# pg.add_dimension('wf_pz_dcr', 1, [f\"{t:.2f}*us\" for t in tau1_arr])\n",
+    "# pg.add_dimension('wf_pz_dcr', 2, [f\"{t:.2f}*us\" for t in tau2_arr])\n",
+    "# print('tau1:', tau1_arr)\n",
+    "# print('tau2:', tau2_arr)\n",
+    "\n",
+    "rise_arr = np.linspace(6, 10, 5)\n",
+    "flat_arr = np.linspace(15, 21, 10)\n",
+    "print(rise_arr)\n",
+    "print(flat_arr)\n",
+    "\n",
+    "pg.add_dimension('wf_dcr_trap', 1, [f\"{t:.2f}*us\" for t in rise_arr])\n",
+    "pg.add_dimension('wf_dcr_trap', 2, [f\"{t:.2f}*us\" for t in flat_arr])\n",
+    "\n",
+    "ngrid = pg.get_n_grid_points()\n",
+    "print('grid points to search:', ngrid)\n",
+    "\n",
+    "# the more waveforms we have, the longer it will take to run one grid point\n",
+    "nwfs = tb_wfs[pk_select]['waveform']['values'].nda.shape[0]\n",
+    "print('wfs to reprocess:', nwfs * ngrid)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a6585b34-33e0-4941-92d4-7fcb1bccfb27",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# run the grid search.\n",
+    "\n",
+    "# NOTE: the fom_function does NOT support additional function arguments.\n",
+    "fom_vals = run_grid(tb_wfs[pk_select], dsp_config, pg, mean_val, db_dict=dsp_db, verbosity=0)\n",
+    "\n",
+    "# unpack the results into a DataFrame.  \n",
+    "# have to iterate thru the n-dimensional grid\n",
+    "grid_nd = []\n",
+    "ix = pg.get_zero_indices()\n",
+    "while True:\n",
+    "    row = []\n",
+    "    for i_dim, i_par in enumerate(ix):\n",
+    "        name, i_arg, value_str, _ = pg.get_data(i_dim, i_par)\n",
+    "        row.append(value_str)\n",
+    "    grid_nd.append(row)\n",
+    "    if not pg.iterate_indices(ix): break\n",
+    "\n",
+    "df_grid = pd.DataFrame(grid_nd, columns=['rise','flat'])\n",
+    "\n",
+    "results_1d = fom_vals.reshape(-1, pg.get_n_grid_points())\n",
+    "df_grid['fom'] = results_1d[0]\n",
+    "\n",
+    "print(\"NOTE: if one of the best settings is at the upper/lower limit of your parameter grid,\",\n",
+    "      \"\\nyou probably need to adjust the grid to find the true min.\")\n",
+    "\n",
+    "# df_grid # show full df\n",
+    "df_best = df_grid.sort_values('fom', key=abs)\n",
+    "df_best[:5]"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6efb3eb9-4b3c-4d08-8e2f-b7d21d11a748",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# update the dsp_db with the best result.\n",
+    "print(df_best.iloc[0])\n",
+    "dbest = df_grid.sort_values('fom', key=abs).iloc[0].to_dict()\n",
+    "\n",
+    "dbest\n",
+    "\n",
+    "for par, val in dbest.items():\n",
+    "    if par == 'fom': continue\n",
+    "    dsp_db['dcr_trap'][par] = val\n",
+    "    \n",
+    "print(\"NOTE: you can go back and re-run the WaveformBrowser step now,\",\n",
+    "     \"\\nto see the effect of the updated values.\")\n",
+    "\n",
+    "dsp_db\n",
+    "\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "6081d4d2-9bc3-49b2-a987-4466728deb37",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "\n",
+    "# run dsp with the best result and check the central value of DCR\n",
+    "\n",
+    "print('DB parameters:')\n",
+    "pprint(dsp_db)\n",
+    "# pprint(dsp_config)\n",
+    "\n",
+    "# run dsp\n",
+    "tb_out = run_one_dsp(tb_wfs[pk_select], dsp_config, db_dict=dsp_db, verbosity=1)\n",
+    "\n",
+    "# print(tb_out.keys())\n",
+    "df = tb_out.get_dataframe()\n",
+    "# print(df)\n",
+    "\n",
+    "# make a quick histogram of the dcr column.  pandas auto-histogram for a column should be enough\n",
+    "df.hist('dcr', bins=int(len(df) * 0.1))\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b7203ad0-4bae-4f2f-a766-62fd4e4e9369",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# load our skim waveforms file\n",
+    "f_input = f_wfs \n",
+    "tb_input = f'{tb_in}/{pk_select}'\n",
+    "\n",
+    "print('DB input parameters:')\n",
+    "pprint(dsp_db)\n",
+    "\n",
+    "b = wfb(f_input, tb_input, dsp_config,\n",
+    "        waveforms=['wf_blsub', 'wf_pz', 'wf_pz_dcr', 'wf_dcr_trap'],\n",
+    "        database=dsp_db,\n",
+    "        # legend=['wf_blsub', 'wf_pz', 'wf_etrap', 'wf_atrap'],\n",
+    "        # lines=['tp_ftp', 'tp_0', 'trapEftp', 'tp_ftp'],\n",
+    "        x_lim=(38000, 80000)\n",
+    "       )"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "037f323b-ace7-404c-9f8f-6cb266d4d07f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "b.draw_next()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "486c6808-c366-478a-9e6d-36bd7d351cee",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "pprint(dsp_db)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "bccb31e2-75bd-41e3-868a-9b969ff41f20",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "#NOTE: now you need to make the 2D plot with DCR vs Energy to see alpha/gamma separation.\n",
+    "# it can take 3-4 minutes to process each file.  \n",
+    "# to speed up the time it takes to search the parameter space, we apply a pretty aggressive low-e cut.\n",
+    "# we can come back and process the full file at the end.\n",
+    "\n",
+    "%time\n",
+    "nlim = 1\n",
+    "files_in = raw_files[:nlim]\n",
+    "\n",
+    "# run dsp on the full file.  can take a while ... 2-3 mins\n",
+    "print('DB parameters:')\n",
+    "pprint(dsp_db)\n",
+    "# pprint(dsp_config)\n",
+    "\n",
+    "# apply low-e cut\n",
+    "# raw_data = pd.DataFrame(lh5.load_nda(raw_file, data_cols, tb_in, verbose=False))\n",
+    "raw_e = lh5.load_nda(raw_files[:nlim], ['energy'], tb_in, verbose=False)\n",
+    "\n",
+    "print(type(raw_e['energy']))\n",
+    "\n",
+    "ix = np.where(raw_e['energy'] > 1e6)[0] # this is somewhere between 583 and 1460. \n",
+    "print(type(ix))\n",
+    "\n",
+    "print(type(raw_files[:nlim]))\n",
+    "\n",
+    "# read the waveforms.  this can take quite a while (3--4 minutes)\n",
+    "tb_data, n_wfs = sto.read_object(tb_in, raw_files[:nlim], idx=ix)\n",
+    "print(n_wfs)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "69faa81c-0d12-4686-bb3a-5ac7cc25a73f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# DEBUG line -- manually vary dsp_db\n",
+    "\n",
+    "# dsp_db['dcr_pz']['tau1'] = \"60*us\" # best: 52*us\n",
+    "# dsp_db['dcr_pz']['tau2'] = \"6.4*us\" # best: 6.4*us\n",
+    "# dsp_db['dcr_pz']['frac'] = \"0.043\" # best: 0.043\n",
+    "# dsp_db['dcr_trap']['rise'] = \"8*us\"\n",
+    "# dsp_db['dcr_trap']['flat']= \"23*us\"\n",
+    "# dsp_db['dcr']['ftp'] = \"80*us\"\n",
+    "\n",
+    "# dcr_trap rise/flat maxes: [10, 20], [9, 22]\n",
+    "\n",
+    "# run the dsp\n",
+    "tb_out = run_one_dsp(tb_data, dsp_config, db_dict=dsp_db, verbosity=0)\n",
+    "print(tb_out.keys())\n",
+    "df_data = tb_out.get_dataframe()\n",
+    "# df_data"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "a26ae05c-a1bf-4768-8259-050ebaac4aed",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%matplotlib widget\n",
+    "from matplotlib.colors import LogNorm\n",
+    "\n",
+    "# 2D plot of DCR vs trapEftp\n",
+    "\n",
+    "xlo, xhi, xpb = 0, 15000, 10\n",
+    "ylo, yhi, ypb = -200, 200, 1\n",
+    "# ylo, yhi, ypb = -600, 0, 1\n",
+    "\n",
+    "nbx = int((xhi-xlo)/xpb)\n",
+    "nby = int((yhi-ylo)/ypb)\n",
+    "\n",
+    "plt.hist2d(df_data.trapEftp, df_data.dcr, range=((xlo,xhi),(ylo,yhi)), bins=(nbx, nby), \n",
+    "           cmap='jet', norm=LogNorm())\n",
+    "\n",
+    "plt.xlabel('trapEftp')\n",
+    "plt.ylabel('dcr')\n",
+    "plt.show()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "9a05495b-7d5a-4edc-9d50-92157c12e274",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# finally, write the DB to an output text file.\n",
+    "\n",
+    "pprint(dsp_db)\n",
+    "\n",
+    "# try writing to a temporary file\n",
+    "f_dsp = './metadata/optimizer_results.json'\n",
+    "with open(f_dsp, 'w') as f:\n",
+    "    json.dump(dsp_db, f)\n",
+    "\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "21441e00-3924-4471-bd23-7d0b83a53bbb",
+   "metadata": {},
+   "source": [
+    "## 4. Charge Trapping Correction\n",
+    "\n",
+    "Two methods:\n",
+    "* Drift Time\n",
+    "* DCR\n",
+    "    \n",
+    "FIRST, you want to optimize the energy trapezoids & pole zero consts by themselves.  \n",
+    "(That's what all the work up to this point was for!)\n",
+    "THEN, you can improve the resolution further by introducing a corrected parameter.\n",
+    "\n",
+    "1. DT method:\n",
+    "Fixed time pickoff should be the same as the ramp time, started at t0.  \n",
+    "Second region must be totally in the flat top, first region starts at t0\n",
+    "The correction should be: `E_new = E + const * qDrift`, but need to check it works for multiple peaks.\n",
+    "It might also be `E_new = E * (1 + const * qDrift)`\n",
+    "\n",
+    "2. DCR method: \n",
+    "DCR-modified E: `E_new = E + const * DCR`\n",
+    "The reason this is as effective as the drift time method is that delayed charge collection from the **bulk** is released w. some time constant on the order of the digitizer window length.  This will make it show up in the tail of the waveforms.  Of course, alpha events have delayed collection from the **surface** as well, which should have a different time constant.\n",
+    "\n",
+    "In MJD, charges are significantly trapped but released before next event due to the lower operating temperature, 77 K.\n",
+    "In the STCs and CAGE, the operating temp is closer to 85 or 90 K, and the charge re-release time will be faster. \n",
+    "So we will have to decide for ourselves if Method 1 (DT) or Method 2 (DCR) is a more effective charge trapping correction for us."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b4981998-8589-4bdc-b3a3-34e878398099",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# lol TBD  :-)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "08237f13-6e97-4060-a13e-13c91d5681cf",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "name": "python"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/tutorials/WaveformBrowserTutorial.ipynb b/tutorials/WaveformBrowserTutorial.ipynb
index 1d02dd804..258ff3c12 100644
--- a/tutorials/WaveformBrowserTutorial.ipynb
+++ b/tutorials/WaveformBrowserTutorial.ipynb
@@ -6,7 +6,11 @@
    "source": [
     "# Introduction\n",
     "\n",
-    "\"Have you tried looking at the waveforms from those events?\"  - David Radford\n",
+    "Ian Guinn, UNC\n",
+    "\n",
+    "Presented at [LEGEND Software Tutorial, Nov. 2021](https://indico.legend-exp.org/event/561/)\n",
+    "\n",
+    "**\"Have you tried looking at the waveforms from those events?\"  - David Radford**\n",
     "\n",
     "This is a tutorial demonstrating several ways to use the Waveform browser to examine data from LEGEND. This will consist of multiple examples, increasing in complexity, and will use data from the PGT. The waveform browser (pygama.dsp.WaveformBrowser) is a pygama utility for accessing waveforms from raw files in an interactive way, enabling you to access, draw, or even process waveforms. Some use cases for this utility include investigating a population of waveforms, and debugging waveform processors.\n",
     "\n",
@@ -37,10 +41,12 @@
     "import os, json\n",
     "\n",
     "# Set input values for where to find our data. This will grab all calibration runs from run 30, parsing wildcards\n",
-    "raw_files = '/home/ian/Documents/test_pygama/LPGTA_*.lh5'\n",
-    "dsp_files = '/home/ian/Documents/test_pygama/t2_LPGTA_*.lh5'\n",
-    "channel = 'g024'\n",
-    "dsp_config_file = \"/home/ian/Documents/test_pygama/dsp_config.json\"\n",
+    "\n",
+    "# pgt_dir = '$LEGENDDATADIR/lngs/pgt/'\n",
+    "pgt_dir = '/global/cfs/cdirs/m2676/data/lngs/pgt/'\n",
+    "raw_files = pgt_dir + 'raw/geds/LPGTA_r0030_*_calib_geds_raw.lh5'\n",
+    "dsp_files = pgt_dir + 'dsp/geds/LPGTA_r0030_*_calib_geds_dsp.lh5'\n",
+    "channel = 'g040'\n",
     "\n",
     "# Set defaults for figures\n",
     "plt.rcParams['figure.figsize'] = (12, 8)\n",
@@ -201,6 +207,9 @@
     "\n",
     "AoE[aoe_accept].hist(bins=200, range=(-0, 0.1))\n",
     "AoE[aoe_cut].hist(bins=200, range=(-0, 0.1))"
+    "# Use the lpgta dsp json file. TODO: get this from DataGroup\n",
+    "# dsp_config_file = os.path.expandvars(\"$HOME/pygama/experiments/lpgta/LPGTA_dsp.json\")\n",
+    "dsp_config_file = os.path.expandvars(\"./metadata/LPGTA_dsp.json\")\n"
    ]
   },
   {
@@ -291,25 +300,18 @@
     "    print(\"TrapEmax:\", en)\n",
     "    print()"
    ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
   }
  ],
  "metadata": {
-  "kernelspec": {
-   "display_name": "Python 3",
-   "language": "python",
-   "name": "python3"
-  },
   "language_info": {
-   "codemirror_mode": {
-    "name": "ipython",
-    "version": 3
-   },
-   "file_extension": ".py",
-   "mimetype": "text/x-python",
-   "name": "python",
-   "nbconvert_exporter": "python",
-   "pygments_lexer": "ipython3",
-   "version": "3.8.10"
+   "name": "python"
   }
  },
  "nbformat": 4,
diff --git a/tutorials/WriteProcessors.ipynb b/tutorials/WriteProcessors.ipynb
new file mode 100644
index 000000000..665e556dd
--- /dev/null
+++ b/tutorials/WriteProcessors.ipynb
@@ -0,0 +1,153 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "a3adc505-d0f6-4e6a-92bf-626e4a347d61",
+   "metadata": {},
+   "source": [
+    "# How to Write a Signal Processor\n",
+    "\n",
+    "Ian Guinn, UNC.  Presented at [LEGEND Software Tutorial, Nov. 2021](https://indico.legend-exp.org/event/561/)\n",
+    "\n",
+    "This tutorial will demonstrate, using several examples, how to write a signal processor. Signal processors are typically written using numba, a python library that generates high performance C-code from python. In particular, we will use the guvectorize command to write generalized ufuncs: https://numpy.org/doc/stable/reference/c-api/generalized-ufuncs.html\n",
+    "\n",
+    "Compared to a typical python function, generalized ufuncs are required to specify more information. In particular, we must provide information about the data types of the inputs, the shape of the arrays that will be provided, and some other details about how the function will be generated. We will generate a few simple examples to demonstrate these concepts; in addition, a list of processors are provided in pygama/dsp/\\_processors, providing even more examples. When you write an actual processor, you will add a new python module to this directory with the function defined, and then register it in pygama/dsp/processors.py, so that pygama has an easy time finding it."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "240ba40d-428c-4aea-822c-02a7a946310c",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import numpy as np\n",
+    "from numba import guvectorize\n",
+    "from pygama.dsp.errors import DSPFatal"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "ed2228e2-9239-4e8d-80bb-03bcfeec5ec1",
+   "metadata": {},
+   "source": [
+    "## Example 1: A simple processor\n",
+    "\n",
+    "For our first, very simple example, we will produce a processor that adds a value to each element of a waveform. The function itself is written in normal looking python. As written, however, this would be a low performing function due to the for loop that it uses (python loops are slow; this is why we like vectorized functions). By compiling this function, numba will convert the loop to C, which will accelerate it considerably.\n",
+    "\n",
+    "It is also important to notice that the output of the function is not returned, but instead is an argument of the function (w_out). While this is somewhat unusual in python, this is called \"passing by reference\" and is common practice in C and other languages; instead of creating a new array and returning it, this function will take a pre-defined memory block and fill it. This further aids the performance of the DSP by avoiding repeated allocation and deallocation of arrays, which can be a slow process. This function can still be called by passing only inputs; in that case the numbafied function will automatically \"broadcast\" the shape and type of the output array from the input values, and create it.\n",
+    "\n",
+    "Prior to the definition of the function, we add the @guvectorize operator, with the following arguments:\n",
+    "1) A list of type signatures. Each signature will have one type for each function argument. The types are all primitive C-types, and can largely be described by a type (floating point, integer, unsigned integer) and a size (in bits). For this example, we are using 32-bit and 64-bit floats (aka doubles). For types that represent arrays and for outputs, we must also append [:]. If you are familiar with C, this can be thought of as passing a pointer\n",
+    "2) A shape-signature, with a parenthesis-enclosed shape variable for each argument. Scalar values are represented as (), 1D arrays by (n), 2D arrays by (n,m), etc. For arrays with the same character, each argument must be the same length (in this example, this means the output w_out must be the same size as w_in). Inputs and outputs are separated by ->; however, inputs can still be modified and treated as outputs. This becomes necessary when the output has a shape different from all inputs!\n",
+    "3) nopython: if set to True, this will force numba to compile C-code, or fail if it is unable to. Alternatively, one can use forceobj mode, which does not actually compile C-code; we will have an example of this soon.\n",
+    "4) cache: if set to True, after compiling the function, we will store it on disk. This avoids the need to recompile the function each time we restart python"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b085bca5-c323-4174-947c-089d71c8fcd8",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "@guvectorize([\"void(float32[:], float32, float32[:])\",\n",
+    "              \"void(float64[:], float64, float64[:])\"],\n",
+    "             \"(n),()->(n)\", nopython=True, cache=True)\n",
+    "def test(a, b, c):\n",
+    "    for i, x in enumerate(a):\n",
+    "        c[i] = x + b\n",
+    "\n",
+    "w_in = np.arange(10)\n",
+    "print(\"test(\"+str(np.arange(10))+\", 3.) = \"+str(test(w_in, 3.)))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "207cbd3f-cd38-4d56-b2f1-9819bbfef536",
+   "metadata": {},
+   "source": [
+    "## Example 2: Wrapping a python function\n",
+    "\n",
+    "Sometimes, we want to be able to run python code from other scientific computing libraries such as scipy. It's cases like this, where we use forceobj instead of nopython mode. For this example, we will use scipy's fast fourier transform to compute."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "ba4a4617-918f-4306-8711-8ec75f5fbe38",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import scipy.fft\n",
+    "\n",
+    "@guvectorize([\"void(float32[:], complex64[:])\",\n",
+    "              \"void(float64[:], complex128[:])\"],\n",
+    "             \"(n)->(n)\", forceobj=True)\n",
+    "def fft(w_in, fft_out):\n",
+    "    fft_out[:] = scipy.fft.fft(w_in)\n",
+    "\n",
+    "w_in = np.arange(10)\n",
+    "print(\"fft(\" + str(w_in) + \") = \" + str(fft(np.arange(10))))\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "da64add8-ef97-42db-9262-d5e9fce96088",
+   "metadata": {},
+   "source": [
+    "## Example 3: Pygama Style Specifications\n",
+    "\n",
+    "For pygama, we have defined additional style specifications, which are laid out in pygama/dsp/\\_processors/template.py. This defines naming schemes and which types to use. It also lays out how to handle exceptions and bounds checking using not-a-numbers (NaNs). Finally, it describes how to document the code using doc-strings.\n",
+    "\n",
+    "We will revisit Example 1, applying these guidelines."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "07378535-02aa-4d70-a17b-6de95b1379c0",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "@guvectorize([\"void(float32[:], float32, float32[:])\",\n",
+    "              \"void(float64[:], float64, float64[:])\"],\n",
+    "             \"(n),()->(n)\", nopython=True, cache=True)\n",
+    "def test(w_in, a_in, w_out):\n",
+    "    \"\"\"This is a test function to demonstrate the basics of writing DSP processors. It adds a constant value to the input waveform.\n",
+    "    Parameters\n",
+    "    ----------\n",
+    "    w_in : input waveform\n",
+    "    a_in : input scalar added to input waveform\n",
+    "    w_out : output waveform\n",
+    "    \"\"\"\n",
+    "    # If scalar is not finite, raise an exception\n",
+    "    if not np.isfinite(a_in):\n",
+    "        raise DSPFatal(\"Input scalar must not be NaN or inf\")\n",
+    "    \n",
+    "    # If any NaNs appear in w_in, set w_out to all NaN\n",
+    "    if np.isnan(w_in).any():\n",
+    "        w_out[:] = np.nan\n",
+    "        return\n",
+    "    \n",
+    "    for i, x in enumerate(w_in):\n",
+    "        w_out[i] = x + a_in\n"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "b592af1c-8f04-4578-aa5f-35ba3a976188",
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "name": "python"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/tutorials/metadata/LPGTA_dsp.json b/tutorials/metadata/LPGTA_dsp.json
new file mode 100644
index 000000000..9db719341
--- /dev/null
+++ b/tutorials/metadata/LPGTA_dsp.json
@@ -0,0 +1,138 @@
+{
+  "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"]
+    },
+    "wf_blsub":{
+      "function": "subtract",
+      "module": "numpy",
+      "args": ["waveform", "bl", "wf_blsub"],
+      "prereqs": ["waveform", "bl"],
+      "unit": "ADC"
+    },
+    "wf_pz": {
+      "function": "pole_zero",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", "db.pz_const", "wf_pz"],
+      "prereqs": ["wf_blsub"],
+      "unit": "ADC",
+      "defaults": { "db.pz_const":"400*us" }
+    },
+    "wf_trap": {
+      "function": "trap_norm",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "round(10*us)", "round(3*us)", "wf_trap"],
+      "prereqs": ["wf_pz"],
+      "unit": "ADC"
+    },
+    "wf_atrap": {
+      "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)"},
+      "unit": "ADC"
+    },
+    "trapE": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["wf_trap", 1, "trapE"],
+      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "unit": "ADC",
+      "prereqs": ["wf_trap"]
+    },
+    "trapEftp": {
+      "function": "fixed_time_pickoff",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_trap", "tp_0+13.5*us", "trapEftp"],
+      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "unit": "ADC",
+      "prereqs": ["wf_trap", "tp_0"]
+    },
+    "curr10": {
+      "comment": "3rd argument allocates memory for a derivative of type float",
+      "function": "avg_current",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", 10, "curr10(len(wf_pz)-10, f)"],
+      "unit": "ADC/sample",
+      "prereqs": ["wf_pz"]
+    },
+    "A_10": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["curr10", 1, "A_10"],
+      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "unit": "ADC",
+      "prereqs": ["curr10"]
+    },
+    "AoE": {
+      "function": "divide",
+      "module": "numpy",
+      "args":["A_10", "trapE", "AoE"],
+      "unit": "1/sample",
+      "prereqs": ["A_10", "trapE"]
+    },
+    "tp_max": {
+      "function": "argmax",
+      "module": "numpy",
+      "args": ["wf_blsub", 1, "tp_max"],
+      "kwargs": {"signature":"(n),()->()", "types":["fi->i"]},
+      "unit": "ns",
+      "prereqs": ["wf_blsub"]
+    },
+    "tp_0": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_atrap", 0, "tmax", 0, "tp_0"],
+      "unit": "ns"
+    },
+    "tp_10": {
+      "function": "time_point_frac",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", 0.1, "tp_max", "tp_10"],
+      "unit": "ns",
+      "prereqs": ["wf_blsub", "tp_max"]
+    },
+    "tp_50": {
+      "function": "time_point_frac",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", 0.5, "tp_max", "tp_50"],
+      "unit": "ns",
+      "prereqs": ["wf_blsub", "tp_max"]
+    },
+    "tp_80": {
+      "function": "time_point_frac",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", 0.8, "tp_max", "tp_80"],
+      "unit": "ns",
+      "prereqs": ["wf_blsub", "tp_max"]
+    },
+    "tp_90": {
+      "function": "time_point_frac",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", 0.9, "tp_max", "tp_90"],
+      "unit": "ns",
+      "prereqs": ["wf_blsub", "tp_max"]
+    },
+    "dcr_raw": {
+      "function": "trap_pickoff",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", 100, 3000, "tp_90+1*us", "dcr_raw"],
+      "unit": "ADC",
+      "prereqs": ["wf_pz", "tp_90"]
+    },
+    "ct_corr": {
+      "function": "trap_pickoff",
+      "module": "pygama.dsp.processors",
+      "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
new file mode 100755
index 000000000..8dfdd2258
--- /dev/null
+++ b/tutorials/metadata/cage.json
@@ -0,0 +1,25 @@
+{
+  "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",
+
+  "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",
+
+  "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",
+
+  "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
new file mode 100644
index 000000000..09bbacd0a
--- /dev/null
+++ b/tutorials/metadata/dsp_config.json
@@ -0,0 +1,194 @@
+{
+  "outputs": [
+    "timestamp", "channel", "trapEmax", "trapEftp", "triE", "bl_mean", "bl_sig", "A_10", "AoE", "dcr", "zacE", "cuspE"
+  ],
+  "processors":{
+    "bl_mean , bl_sig, bl_slope, bl_intercept":{
+      "function": "linear_slope_fit",
+      "module": "pygama.dsp.processors",
+      "args" : ["waveform[0: 1000]", "bl_mean","bl_sig", "bl_slope","bl_intercept"],
+      "unit": ["ADC","ADC","ADC","ADC"]
+    },
+    "wf_blsub":{
+      "function": "subtract",
+      "module": "numpy",
+      "args": ["waveform", "bl_mean", "wf_blsub"],
+      "unit": "ADC"
+    },
+    "wf_pz": {
+      "function": "pole_zero",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", "db.pz_const", "wf_pz"],
+      "unit": "ADC",
+      "defaults": { "db.pz_const":"72*us" }
+    },
+    "wf_trap": {
+      "function": "trap_norm",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "round(10*us)", "round(5*us)", "wf_trap"],
+      "unit": "ADC"
+    },
+    "wf_triangle": {
+      "function": "trap_norm",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "100*ns", "10*ns", "wf_triangle"],
+      "unit": "ADC"
+    },
+    "wf_atrap": {
+      "function": "asym_trap_filter",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", "round(0.05*us)", "round(2*us)", "round(3*us)", "wf_atrap"],
+      "unit": "ADC"
+    },
+    "tp_min, tp_max, wf_min, wf_max":{
+      "function": "min_max",
+      "module": "pygama.dsp.processors",
+      "args": ["waveform", "tp_min", "tp_max", "wf_min", "wf_max"],
+      "unit": ["ns","ns","ADC", "ADC"]
+    },
+    "tp_0": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_atrap", 0, "tp_max", 0, "tp_0"],
+      "unit": "ns"
+     },
+    "tp_95": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "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"],
+      "unit": "ns"
+     },
+    "tp_50": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "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"],
+      "unit": "ns"
+     },
+    "tp_05": {
+      "function": "time_point_thresh",
+      "module": "pygama.dsp.processors",
+      "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+round(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"]},
+      "unit": "ADC"
+    },
+    "triE": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["wf_triangle", 1, "triE"],
+      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "unit": "ADC"
+    },
+    "curr10": {
+      "comment": "3rd argument allocates memory for a derivative of type float",
+      "function": "avg_current",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", 10, "curr10(len(wf_pz)-10, f)"],
+      "unit": "ADC/sample"
+    },
+    "A_10": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["curr10", 1, "A_10"],
+      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "unit": "ADC"
+    },
+    "AoE": {
+      "function": "divide",
+      "module": "numpy",
+      "args":["A_10", "trapEmax", "AoE"],
+      "unit": "1/sample"
+    },
+    "zac_wf": {
+      "function": "zac_filter",
+      "module": "pygama.dsp.processors",
+      "args": ["waveform", "zac_wf(101, f)"],
+      "init_args": ["len(waveform)", "3.5*us", "2.5*us", "72*us"],
+      "unit": "ADC"
+    },
+    "zacE": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["zac_wf", 1, "zacE"],
+      "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)", "10*us", "1*us", "200*us"],
+      "unit": "ADC"
+    },
+    "cuspE": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["cusp_wf", 1, "cuspE"],
+      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "unit": "ADC"
+    },
+    "curr": {
+      "function": "avg_current",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_pz", 1, "curr(len(wf_pz)-1, f)"],
+      "unit": "ADC/sample"
+    },
+    "curr_psd": {
+      "function": "psd",
+      "module": "pygama.dsp.processors",
+      "args": ["curr", "curr_psd"],
+      "init_args": ["curr", "curr_psd(len(curr)//2+1, f)"],
+      "unit": ""
+    },
+    "hf_max": {
+      "function": "amax",
+      "module": "numpy",
+      "args": ["curr_psd[round(10*mhz*len(curr)):]", 1, "hf_max"],
+      "kwargs": {"signature":"(n),()->()", "types":["fi->f"]},
+      "unit": ""
+    },
+    "power_spect": {
+      "function": "psd",
+      "module": "pygama.dsp.processors",
+      "args": ["wf_blsub", "power_spect"],
+      "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
new file mode 100644
index 000000000..82149dba4
--- /dev/null
+++ b/tutorials/metadata/optimizer_results.json
@@ -0,0 +1,1 @@
+{"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"}}
\ No newline at end of file


Test patch:
diff --git a/pygama/math/test/test_fwhm.py b/pygama/math/test/test_fwhm.py
new file mode 100644
index 000000000..56d86431f
--- /dev/null
+++ b/pygama/math/test/test_fwhm.py
@@ -0,0 +1,33 @@
+import numpy as np
+from numpy.testing import assert_
+import pygama.math.peak_fitting as pgf
+
+
+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-2, 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 = pgf.radford_fwhm(sig, htail, tau, cov)
+    assert_(np.absolute(fwhm - 2.3548) < 1e-4, f'got {fwhm}')
+    assert_(np.absolute(dfwhm - 0.23548) < 1e-5, f'got {dfwhm}')
+
+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-2, 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 = pgf.radford_fwhm(sig, htail, tau, cov)
+    assert_(np.absolute(fwhm - np.log(2)) < 1e-4, f'got {fwhm}')
+    assert_(np.absolute(dfwhm - np.log(2)/10) < 1e-5, f'got {dfwhm}')
+
diff --git a/pygama/math/test/unit_test.py b/pygama/math/test/unit_test.py
deleted file mode 100644
index a82d1b41e..000000000
--- a/pygama/math/test/unit_test.py
+++ /dev/null
@@ -1,1 +0,0 @@
-#placeholder
diff --git a/test/lh5/test_types.py b/test/lh5/test_types.py
new file mode 100644
index 000000000..02ffd8f27
--- /dev/null
+++ b/test/lh5/test_types.py
@@ -0,0 +1,6 @@
+import pygama.lgdo as lgdo
+
+
+def test_array():
+    a = lgdo.Array(shape=(1), dtype=float)
+    assert a.dataype_name() == 'array'