Repository: MideTechnology/endaq-python (language: python)

Issue:
Feature/adding example scripts



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index 3dcd127..8f70735 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,6 +81,9 @@ coverage.xml
 .pytest_cache/
 cover/
 
+# pytest-profiling
+prof/
+
 # Translations
 *.mo
 *.pot
@@ -100,6 +103,7 @@ instance/
 
 # Sphinx documentation
 docs/_build/
+docs/_doctest/
 
 # PyBuilder
 .pybuilder/
diff --git a/README.md b/README.md
index d01245c..6af4259 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,11 @@ This package consists of several submodules, you can read more about how these a
 
 The docs for this package can be found [here](https://docs.endaq.com/en/latest/).
 
+To locally build the [Sphinx](https://www.sphinx-doc.org) documentation from a clone of the repo:
+1. `cd <repo root dir>`
+2. `pip install -e .[docs]`
+3. `sphinx-build -W -b html docs docs/_build`
+
 ## License 
 
 The endaq-python repository is licensed under the MIT license. The full text can be found in the [LICENSE file](https://github.com/MideTechnology/endaq-python/blob/main/LICENSE).
diff --git a/docs/api_ref.rst b/docs/api_ref.rst
index 2c54740..b1bb277 100644
--- a/docs/api_ref.rst
+++ b/docs/api_ref.rst
@@ -2,7 +2,7 @@ enDAQ API Reference
 ===================
 
 .. toctree::
-   :maxdepth: 2
+   :maxdepth: 3
    :caption: Contents:
 
    endaq/ide
diff --git a/docs/endaq/batch.rst b/docs/endaq/batch.rst
index 2f17798..fefb117 100644
--- a/docs/endaq/batch.rst
+++ b/docs/endaq/batch.rst
@@ -1,11 +1,13 @@
-#############
-`endaq.batch`
-#############
+###############
+``endaq.batch``
+###############
 
 
 .. automodule:: endaq.batch
   :members:
   :imported-members:
 
-.. autoclass:: endaq.batch.calc.OutputStruct
+.. autoclass:: endaq.batch.core.OutputStruct
   :members:
+
+  .. autoattribute:: dataframes
diff --git a/docs/endaq/calc.rst b/docs/endaq/calc.rst
index daa1c13..2c00e61 100644
--- a/docs/endaq/calc.rst
+++ b/docs/endaq/calc.rst
@@ -1,46 +1,56 @@
-############
-`endaq.calc`
-############
+##############
+``endaq.calc``
+##############
 
+:py:mod:`endaq.calc` is a module comprising a collection of common calculations
+for vibration analysis. It leverages the standard Python scientific
+stack (NumPy, SciPy, Pandas) in order to enable engineers to perform
+domain-specific calculations in a few lines of code, without having to
+first master Python and its scientific stack in their entireties.
 
+.. toctree::
+   :maxdepth: 2
 
-`endaq.calc.filters`
-====================
+   calc_usage
+
+
+``endaq.calc.filters``
+======================
 
 .. automodule:: endaq.calc.filters
   :members:
 
 
-`endaq.calc.integrate`
-======================
+``endaq.calc.integrate``
+========================
 
 .. automodule:: endaq.calc.integrate
   :members:
 
 
-`endaq.calc.psd`
-================
+``endaq.calc.psd``
+==================
 
 .. automodule:: endaq.calc.psd
   :members:
 
 
-`endaq.calc.shock`
-==================
+``endaq.calc.shock``
+====================
 
 .. automodule:: endaq.calc.shock
   :members:
 
 
-`endaq.calc.stats`
-==================
+``endaq.calc.stats``
+====================
 
 .. automodule:: endaq.calc.stats
   :members:
 
 
-`endaq.calc.utils`
-==================
+``endaq.calc.utils``
+====================
 
 .. automodule:: endaq.calc.utils
   :members:
diff --git a/docs/endaq/calc_usage.rst b/docs/endaq/calc_usage.rst
new file mode 100644
index 0000000..000d473
--- /dev/null
+++ b/docs/endaq/calc_usage.rst
@@ -0,0 +1,66 @@
+=============================
+``endaq.calc`` Usage Examples
+=============================
+
+
+Filters
+~~~~~~~
+
+.. code:: python
+
+   df_accel_highpass = endaq.calc.filters.butterworth(df_accel, low_cutoff=1, high_cutoff=None)
+   df_accel_lowpass = endaq.calc.filters.butterworth(df_accel, low_cutoff=None, high_cutoff=100)
+
+Integration
+~~~~~~~~~~~
+
+.. code:: python
+
+   dfs_integrate = endaq.calc.integrate.integrals(df_accel, n=2, highpass_cutoff=1.0, tukey_percent=0.05)
+
+PSD
+~~~
+
+Linearly-spaced
+^^^^^^^^^^^^^^^
+
+.. code:: python
+
+   df_accel_psd = endaq.calc.psd.welch(df_accel, bin_width=1/11)
+
+Octave-spaced
+^^^^^^^^^^^^^
+
+.. code:: python
+
+   df_accel_psd_oct = endaq.calc.psd.to_octave(df_accel_psd, fstart=1, octave_bins=3)
+
+Derivatives & Integrals
+^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code:: python
+
+   df_vel_psd = endaq.calc.psd.differentiate(df_accel_psd, n=-1)
+   df_jerk_psd = endaq.calc.psd.differentiate(df_accel_psd, n=1)
+
+Vibration Criterion (VC) Curves
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code:: python
+
+   df_accel_vc = endaq.calc.psd.vc_curves(df_accel_psd, fstart=1, octave_bins=3)
+
+Shock Analysis
+~~~~~~~~~~~~~~
+
+.. code:: python
+
+   df_accel_pvss = endaq.calc.shock.shock_spectrum(df_accel, freqs=2 ** np.arange(-10, 13, 0.25), damp=0.05, mode="pvss")
+   df_accel_srs = endaq.calc.shock.shock_spectrum(df_accel, freqs=[1, 10, 100, 1000], damp=0.05, mode="srs")
+
+Shock Characterization: Half-Sine-Wave Pulse
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. code:: python
+
+   half_sine_params = endaq.calc.shock.enveloping_half_sine(df_accel_pvss, damp=0.05)
diff --git a/docs/endaq/cloud.rst b/docs/endaq/cloud.rst
index 6fa6e9b..b7a0b8c 100644
--- a/docs/endaq/cloud.rst
+++ b/docs/endaq/cloud.rst
@@ -1,6 +1,13 @@
-#############
-`endaq.cloud`
-#############
+###############
+``endaq.cloud``
+###############
+
+This module houses tools and functions to make both interacting with the enDAQ Cloud API and the [enDAQ Cloud](https://cloud.endaq.com) itself easier.
+
+.. toctree::
+   :maxdepth: 2
+
+   cloud_API_wrapper
 
 
 `endaq.cloud.core`
diff --git a/docs/endaq/cloud_API_wrapper.rst b/docs/endaq/cloud_API_wrapper.rst
index 9f129fb..6ac5fa1 100644
--- a/docs/endaq/cloud_API_wrapper.rst
+++ b/docs/endaq/cloud_API_wrapper.rst
@@ -1,6 +1,6 @@
-#########################
-`endaq.cloud` API Wrapper
-#########################
+#######################
+enDAQ Cloud API Wrapper
+#######################
 
 The API Wrapper provides a simple command-line interface for accessing basic file and device information from the enDAQ Cloud API. Output of all commands except ``account`` and `attributes` are in ``csv`` files in the ``output`` folder.
 
diff --git a/docs/endaq/ide.rst b/docs/endaq/ide.rst
index d771d30..436d01f 100644
--- a/docs/endaq/ide.rst
+++ b/docs/endaq/ide.rst
@@ -1,7 +1,15 @@
-###########
-`endaq.ide`
-###########
+#############
+``endaq.ide``
+#############
 
+The submodule ``endaq.ide`` contains utility functions for accessing data in *Mide Instrumentation Data Exchange*
+(``.IDE``) files, the native format generated by enDAQ data recorders.
+
+.. toctree::
+   :maxdepth: 2
+
+   ide_measurement
+   ide_usage
 
 .. automodule:: endaq.ide
   :members:
diff --git a/docs/endaq/ide_measurement.rst b/docs/endaq/ide_measurement.rst
new file mode 100644
index 0000000..26199ff
--- /dev/null
+++ b/docs/endaq/ide_measurement.rst
@@ -0,0 +1,8 @@
+#########################
+``endaq.ide.measurement``
+#########################
+
+
+.. automodule:: endaq.ide.measurement
+   :members:
+   :exclude-members: filter_channels, get_channels, get_measurement_type
\ No newline at end of file
diff --git a/docs/endaq/ide_usage.rst b/docs/endaq/ide_usage.rst
new file mode 100644
index 0000000..dadb554
--- /dev/null
+++ b/docs/endaq/ide_usage.rst
@@ -0,0 +1,685 @@
+############################
+``endaq.ide`` Usage Examples
+############################
+
+Note: For brevity, the following examples assume everything has been imported
+from :py:mod:`endaq.ide`:
+
+.. code:: python3
+
+    from endaq.ide import *
+
+Opening IDE files: :py:func:`endaq.ide.get_doc()`
+-------------------------------------------------
+
+:py:mod:`endaq.ide` includes a convenient shortcut for importing IDE data:
+:py:func:`~endaq.ide.get_doc()`. It can load data from local files, or read data directly
+from a URL.
+
+.. code:: python3
+
+    doc = get_doc("tests/test.ide")
+    doc1 = get_doc("https://info.endaq.com/hubfs/data/surgical-instrument.ide")
+
+IDE files can be retrieved directly from Google Drive using a Drive
+'sharable link' URL. The file must be set to allow access to "Anyone
+with the link."
+
+.. code:: python3
+
+    doc2 = get_doc("https://drive.google.com/file/d/1t3JqbZGhuZbIK9agH24YZIdVE26-NOF5/view?usp=sharing")
+
+Whether opening a local file or a URL, :py:func:`~endaq.ide.get_doc()` can be used to
+import only a specific interval by way of its ``start`` and ``end``
+parameters:
+
+.. code:: python3
+
+    doc3 = get_doc("tests/test.ide", start="5s", end="10s")
+
+Summarizing IDE files: :py:func:`endaq.ide.get_channel_table()`
+---------------------------------------------------------------
+
+Once an IDE file has been loaded, :py:func:`~endaq.ide.get_channel_table()` will
+retrieve basic summary information about its contents.
+
+.. code:: python3
+
+    get_channel_table(doc)
+
+
+
+
+.. raw:: html
+
+    <style type="text/css">
+    </style>
+    <table id="T_d2cbb_">
+      <thead>
+        <tr>
+          <th class="blank level0" >&nbsp;</th>
+          <th class="col_heading level0 col0" >channel</th>
+          <th class="col_heading level0 col1" >name</th>
+          <th class="col_heading level0 col2" >type</th>
+          <th class="col_heading level0 col3" >units</th>
+          <th class="col_heading level0 col4" >start</th>
+          <th class="col_heading level0 col5" >end</th>
+          <th class="col_heading level0 col6" >duration</th>
+          <th class="col_heading level0 col7" >samples</th>
+          <th class="col_heading level0 col8" >rate</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr>
+          <th id="T_d2cbb_level0_row0" class="row_heading level0 row0" >0</th>
+          <td id="T_d2cbb_row0_col0" class="data row0 col0" >32.0</td>
+          <td id="T_d2cbb_row0_col1" class="data row0 col1" >X (16g)</td>
+          <td id="T_d2cbb_row0_col2" class="data row0 col2" >Acceleration</td>
+          <td id="T_d2cbb_row0_col3" class="data row0 col3" >g</td>
+          <td id="T_d2cbb_row0_col4" class="data row0 col4" >00:00.0952</td>
+          <td id="T_d2cbb_row0_col5" class="data row0 col5" >00:19.0012</td>
+          <td id="T_d2cbb_row0_col6" class="data row0 col6" >00:18.0059</td>
+          <td id="T_d2cbb_row0_col7" class="data row0 col7" >7113</td>
+          <td id="T_d2cbb_row0_col8" class="data row0 col8" >393.86 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row1" class="row_heading level0 row1" >1</th>
+          <td id="T_d2cbb_row1_col0" class="data row1 col0" >32.1</td>
+          <td id="T_d2cbb_row1_col1" class="data row1 col1" >Y (16g)</td>
+          <td id="T_d2cbb_row1_col2" class="data row1 col2" >Acceleration</td>
+          <td id="T_d2cbb_row1_col3" class="data row1 col3" >g</td>
+          <td id="T_d2cbb_row1_col4" class="data row1 col4" >00:00.0952</td>
+          <td id="T_d2cbb_row1_col5" class="data row1 col5" >00:19.0012</td>
+          <td id="T_d2cbb_row1_col6" class="data row1 col6" >00:18.0059</td>
+          <td id="T_d2cbb_row1_col7" class="data row1 col7" >7113</td>
+          <td id="T_d2cbb_row1_col8" class="data row1 col8" >393.86 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row2" class="row_heading level0 row2" >2</th>
+          <td id="T_d2cbb_row2_col0" class="data row2 col0" >32.2</td>
+          <td id="T_d2cbb_row2_col1" class="data row2 col1" >Z (16g)</td>
+          <td id="T_d2cbb_row2_col2" class="data row2 col2" >Acceleration</td>
+          <td id="T_d2cbb_row2_col3" class="data row2 col3" >g</td>
+          <td id="T_d2cbb_row2_col4" class="data row2 col4" >00:00.0952</td>
+          <td id="T_d2cbb_row2_col5" class="data row2 col5" >00:19.0012</td>
+          <td id="T_d2cbb_row2_col6" class="data row2 col6" >00:18.0059</td>
+          <td id="T_d2cbb_row2_col7" class="data row2 col7" >7113</td>
+          <td id="T_d2cbb_row2_col8" class="data row2 col8" >393.86 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row3" class="row_heading level0 row3" >3</th>
+          <td id="T_d2cbb_row3_col0" class="data row3 col0" >80.0</td>
+          <td id="T_d2cbb_row3_col1" class="data row3 col1" >X (8g)</td>
+          <td id="T_d2cbb_row3_col2" class="data row3 col2" >Acceleration</td>
+          <td id="T_d2cbb_row3_col3" class="data row3 col3" >g</td>
+          <td id="T_d2cbb_row3_col4" class="data row3 col4" >00:00.0948</td>
+          <td id="T_d2cbb_row3_col5" class="data row3 col5" >00:19.0013</td>
+          <td id="T_d2cbb_row3_col6" class="data row3 col6" >00:18.0064</td>
+          <td id="T_d2cbb_row3_col7" class="data row3 col7" >9070</td>
+          <td id="T_d2cbb_row3_col8" class="data row3 col8" >502.09 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row4" class="row_heading level0 row4" >4</th>
+          <td id="T_d2cbb_row4_col0" class="data row4 col0" >80.1</td>
+          <td id="T_d2cbb_row4_col1" class="data row4 col1" >Y (8g)</td>
+          <td id="T_d2cbb_row4_col2" class="data row4 col2" >Acceleration</td>
+          <td id="T_d2cbb_row4_col3" class="data row4 col3" >g</td>
+          <td id="T_d2cbb_row4_col4" class="data row4 col4" >00:00.0948</td>
+          <td id="T_d2cbb_row4_col5" class="data row4 col5" >00:19.0013</td>
+          <td id="T_d2cbb_row4_col6" class="data row4 col6" >00:18.0064</td>
+          <td id="T_d2cbb_row4_col7" class="data row4 col7" >9070</td>
+          <td id="T_d2cbb_row4_col8" class="data row4 col8" >502.09 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row5" class="row_heading level0 row5" >5</th>
+          <td id="T_d2cbb_row5_col0" class="data row5 col0" >80.2</td>
+          <td id="T_d2cbb_row5_col1" class="data row5 col1" >Z (8g)</td>
+          <td id="T_d2cbb_row5_col2" class="data row5 col2" >Acceleration</td>
+          <td id="T_d2cbb_row5_col3" class="data row5 col3" >g</td>
+          <td id="T_d2cbb_row5_col4" class="data row5 col4" >00:00.0948</td>
+          <td id="T_d2cbb_row5_col5" class="data row5 col5" >00:19.0013</td>
+          <td id="T_d2cbb_row5_col6" class="data row5 col6" >00:18.0064</td>
+          <td id="T_d2cbb_row5_col7" class="data row5 col7" >9070</td>
+          <td id="T_d2cbb_row5_col8" class="data row5 col8" >502.09 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row6" class="row_heading level0 row6" >6</th>
+          <td id="T_d2cbb_row6_col0" class="data row6 col0" >36.0</td>
+          <td id="T_d2cbb_row6_col1" class="data row6 col1" >Pressure/Temperature:00</td>
+          <td id="T_d2cbb_row6_col2" class="data row6 col2" >Pressure</td>
+          <td id="T_d2cbb_row6_col3" class="data row6 col3" >Pa</td>
+          <td id="T_d2cbb_row6_col4" class="data row6 col4" >00:00.0945</td>
+          <td id="T_d2cbb_row6_col5" class="data row6 col5" >00:19.0175</td>
+          <td id="T_d2cbb_row6_col6" class="data row6 col6" >00:18.0230</td>
+          <td id="T_d2cbb_row6_col7" class="data row6 col7" >20</td>
+          <td id="T_d2cbb_row6_col8" class="data row6 col8" >1.10 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row7" class="row_heading level0 row7" >7</th>
+          <td id="T_d2cbb_row7_col0" class="data row7 col0" >36.1</td>
+          <td id="T_d2cbb_row7_col1" class="data row7 col1" >Pressure/Temperature:01</td>
+          <td id="T_d2cbb_row7_col2" class="data row7 col2" >Temperature</td>
+          <td id="T_d2cbb_row7_col3" class="data row7 col3" >°C</td>
+          <td id="T_d2cbb_row7_col4" class="data row7 col4" >00:00.0945</td>
+          <td id="T_d2cbb_row7_col5" class="data row7 col5" >00:19.0175</td>
+          <td id="T_d2cbb_row7_col6" class="data row7 col6" >00:18.0230</td>
+          <td id="T_d2cbb_row7_col7" class="data row7 col7" >20</td>
+          <td id="T_d2cbb_row7_col8" class="data row7 col8" >1.10 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row8" class="row_heading level0 row8" >8</th>
+          <td id="T_d2cbb_row8_col0" class="data row8 col0" >70.0</td>
+          <td id="T_d2cbb_row8_col1" class="data row8 col1" >X</td>
+          <td id="T_d2cbb_row8_col2" class="data row8 col2" >Quaternion</td>
+          <td id="T_d2cbb_row8_col3" class="data row8 col3" >q</td>
+          <td id="T_d2cbb_row8_col4" class="data row8 col4" >00:01.0132</td>
+          <td id="T_d2cbb_row8_col5" class="data row8 col5" >00:18.0954</td>
+          <td id="T_d2cbb_row8_col6" class="data row8 col6" >00:17.0821</td>
+          <td id="T_d2cbb_row8_col7" class="data row8 col7" >1755</td>
+          <td id="T_d2cbb_row8_col8" class="data row8 col8" >98.47 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row9" class="row_heading level0 row9" >9</th>
+          <td id="T_d2cbb_row9_col0" class="data row9 col0" >70.1</td>
+          <td id="T_d2cbb_row9_col1" class="data row9 col1" >Y</td>
+          <td id="T_d2cbb_row9_col2" class="data row9 col2" >Quaternion</td>
+          <td id="T_d2cbb_row9_col3" class="data row9 col3" >q</td>
+          <td id="T_d2cbb_row9_col4" class="data row9 col4" >00:01.0132</td>
+          <td id="T_d2cbb_row9_col5" class="data row9 col5" >00:18.0954</td>
+          <td id="T_d2cbb_row9_col6" class="data row9 col6" >00:17.0821</td>
+          <td id="T_d2cbb_row9_col7" class="data row9 col7" >1755</td>
+          <td id="T_d2cbb_row9_col8" class="data row9 col8" >98.47 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row10" class="row_heading level0 row10" >10</th>
+          <td id="T_d2cbb_row10_col0" class="data row10 col0" >70.2</td>
+          <td id="T_d2cbb_row10_col1" class="data row10 col1" >Z</td>
+          <td id="T_d2cbb_row10_col2" class="data row10 col2" >Quaternion</td>
+          <td id="T_d2cbb_row10_col3" class="data row10 col3" >q</td>
+          <td id="T_d2cbb_row10_col4" class="data row10 col4" >00:01.0132</td>
+          <td id="T_d2cbb_row10_col5" class="data row10 col5" >00:18.0954</td>
+          <td id="T_d2cbb_row10_col6" class="data row10 col6" >00:17.0821</td>
+          <td id="T_d2cbb_row10_col7" class="data row10 col7" >1755</td>
+          <td id="T_d2cbb_row10_col8" class="data row10 col8" >98.47 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row11" class="row_heading level0 row11" >11</th>
+          <td id="T_d2cbb_row11_col0" class="data row11 col0" >70.3</td>
+          <td id="T_d2cbb_row11_col1" class="data row11 col1" >W</td>
+          <td id="T_d2cbb_row11_col2" class="data row11 col2" >Quaternion</td>
+          <td id="T_d2cbb_row11_col3" class="data row11 col3" >q</td>
+          <td id="T_d2cbb_row11_col4" class="data row11 col4" >00:01.0132</td>
+          <td id="T_d2cbb_row11_col5" class="data row11 col5" >00:18.0954</td>
+          <td id="T_d2cbb_row11_col6" class="data row11 col6" >00:17.0821</td>
+          <td id="T_d2cbb_row11_col7" class="data row11 col7" >1755</td>
+          <td id="T_d2cbb_row11_col8" class="data row11 col8" >98.47 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row12" class="row_heading level0 row12" >12</th>
+          <td id="T_d2cbb_row12_col0" class="data row12 col0" >59.0</td>
+          <td id="T_d2cbb_row12_col1" class="data row12 col1" >Control Pad Pressure</td>
+          <td id="T_d2cbb_row12_col2" class="data row12 col2" >Pressure</td>
+          <td id="T_d2cbb_row12_col3" class="data row12 col3" >Pa</td>
+          <td id="T_d2cbb_row12_col4" class="data row12 col4" >00:00.0979</td>
+          <td id="T_d2cbb_row12_col5" class="data row12 col5" >00:18.0910</td>
+          <td id="T_d2cbb_row12_col6" class="data row12 col6" >00:17.0931</td>
+          <td id="T_d2cbb_row12_col7" class="data row12 col7" >180</td>
+          <td id="T_d2cbb_row12_col8" class="data row12 col8" >10.04 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row13" class="row_heading level0 row13" >13</th>
+          <td id="T_d2cbb_row13_col0" class="data row13 col0" >59.1</td>
+          <td id="T_d2cbb_row13_col1" class="data row13 col1" >Control Pad Temperature</td>
+          <td id="T_d2cbb_row13_col2" class="data row13 col2" >Temperature</td>
+          <td id="T_d2cbb_row13_col3" class="data row13 col3" >°C</td>
+          <td id="T_d2cbb_row13_col4" class="data row13 col4" >00:00.0979</td>
+          <td id="T_d2cbb_row13_col5" class="data row13 col5" >00:18.0910</td>
+          <td id="T_d2cbb_row13_col6" class="data row13 col6" >00:17.0931</td>
+          <td id="T_d2cbb_row13_col7" class="data row13 col7" >180</td>
+          <td id="T_d2cbb_row13_col8" class="data row13 col8" >10.04 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row14" class="row_heading level0 row14" >14</th>
+          <td id="T_d2cbb_row14_col0" class="data row14 col0" >76.0</td>
+          <td id="T_d2cbb_row14_col1" class="data row14 col1" >Lux</td>
+          <td id="T_d2cbb_row14_col2" class="data row14 col2" >Light</td>
+          <td id="T_d2cbb_row14_col3" class="data row14 col3" >Ill</td>
+          <td id="T_d2cbb_row14_col4" class="data row14 col4" >00:00.0000</td>
+          <td id="T_d2cbb_row14_col5" class="data row14 col5" >00:18.0737</td>
+          <td id="T_d2cbb_row14_col6" class="data row14 col6" >00:18.0737</td>
+          <td id="T_d2cbb_row14_col7" class="data row14 col7" >71</td>
+          <td id="T_d2cbb_row14_col8" class="data row14 col8" >3.79 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_d2cbb_level0_row15" class="row_heading level0 row15" >15</th>
+          <td id="T_d2cbb_row15_col0" class="data row15 col0" >76.1</td>
+          <td id="T_d2cbb_row15_col1" class="data row15 col1" >UV</td>
+          <td id="T_d2cbb_row15_col2" class="data row15 col2" >Light</td>
+          <td id="T_d2cbb_row15_col3" class="data row15 col3" >Index</td>
+          <td id="T_d2cbb_row15_col4" class="data row15 col4" >00:00.0000</td>
+          <td id="T_d2cbb_row15_col5" class="data row15 col5" >00:18.0737</td>
+          <td id="T_d2cbb_row15_col6" class="data row15 col6" >00:18.0737</td>
+          <td id="T_d2cbb_row15_col7" class="data row15 col7" >71</td>
+          <td id="T_d2cbb_row15_col8" class="data row15 col8" >3.79 Hz</td>
+        </tr>
+      </tbody>
+    </table>
+
+
+
+
+The results can be filtered by :doc:`measurement type <ide_measurement>`:
+
+.. code:: python3
+
+    get_channel_table(doc, ACCELERATION)
+
+
+
+
+.. raw:: html
+
+    <style type="text/css">
+    </style>
+    <table id="T_9f9cf_">
+      <thead>
+        <tr>
+          <th class="blank level0" >&nbsp;</th>
+          <th class="col_heading level0 col0" >channel</th>
+          <th class="col_heading level0 col1" >name</th>
+          <th class="col_heading level0 col2" >type</th>
+          <th class="col_heading level0 col3" >units</th>
+          <th class="col_heading level0 col4" >start</th>
+          <th class="col_heading level0 col5" >end</th>
+          <th class="col_heading level0 col6" >duration</th>
+          <th class="col_heading level0 col7" >samples</th>
+          <th class="col_heading level0 col8" >rate</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr>
+          <th id="T_9f9cf_level0_row0" class="row_heading level0 row0" >0</th>
+          <td id="T_9f9cf_row0_col0" class="data row0 col0" >32.0</td>
+          <td id="T_9f9cf_row0_col1" class="data row0 col1" >X (16g)</td>
+          <td id="T_9f9cf_row0_col2" class="data row0 col2" >Acceleration</td>
+          <td id="T_9f9cf_row0_col3" class="data row0 col3" >g</td>
+          <td id="T_9f9cf_row0_col4" class="data row0 col4" >00:00.0952</td>
+          <td id="T_9f9cf_row0_col5" class="data row0 col5" >00:19.0012</td>
+          <td id="T_9f9cf_row0_col6" class="data row0 col6" >00:18.0059</td>
+          <td id="T_9f9cf_row0_col7" class="data row0 col7" >7113</td>
+          <td id="T_9f9cf_row0_col8" class="data row0 col8" >393.86 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_9f9cf_level0_row1" class="row_heading level0 row1" >1</th>
+          <td id="T_9f9cf_row1_col0" class="data row1 col0" >32.1</td>
+          <td id="T_9f9cf_row1_col1" class="data row1 col1" >Y (16g)</td>
+          <td id="T_9f9cf_row1_col2" class="data row1 col2" >Acceleration</td>
+          <td id="T_9f9cf_row1_col3" class="data row1 col3" >g</td>
+          <td id="T_9f9cf_row1_col4" class="data row1 col4" >00:00.0952</td>
+          <td id="T_9f9cf_row1_col5" class="data row1 col5" >00:19.0012</td>
+          <td id="T_9f9cf_row1_col6" class="data row1 col6" >00:18.0059</td>
+          <td id="T_9f9cf_row1_col7" class="data row1 col7" >7113</td>
+          <td id="T_9f9cf_row1_col8" class="data row1 col8" >393.86 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_9f9cf_level0_row2" class="row_heading level0 row2" >2</th>
+          <td id="T_9f9cf_row2_col0" class="data row2 col0" >32.2</td>
+          <td id="T_9f9cf_row2_col1" class="data row2 col1" >Z (16g)</td>
+          <td id="T_9f9cf_row2_col2" class="data row2 col2" >Acceleration</td>
+          <td id="T_9f9cf_row2_col3" class="data row2 col3" >g</td>
+          <td id="T_9f9cf_row2_col4" class="data row2 col4" >00:00.0952</td>
+          <td id="T_9f9cf_row2_col5" class="data row2 col5" >00:19.0012</td>
+          <td id="T_9f9cf_row2_col6" class="data row2 col6" >00:18.0059</td>
+          <td id="T_9f9cf_row2_col7" class="data row2 col7" >7113</td>
+          <td id="T_9f9cf_row2_col8" class="data row2 col8" >393.86 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_9f9cf_level0_row3" class="row_heading level0 row3" >3</th>
+          <td id="T_9f9cf_row3_col0" class="data row3 col0" >80.0</td>
+          <td id="T_9f9cf_row3_col1" class="data row3 col1" >X (8g)</td>
+          <td id="T_9f9cf_row3_col2" class="data row3 col2" >Acceleration</td>
+          <td id="T_9f9cf_row3_col3" class="data row3 col3" >g</td>
+          <td id="T_9f9cf_row3_col4" class="data row3 col4" >00:00.0948</td>
+          <td id="T_9f9cf_row3_col5" class="data row3 col5" >00:19.0013</td>
+          <td id="T_9f9cf_row3_col6" class="data row3 col6" >00:18.0064</td>
+          <td id="T_9f9cf_row3_col7" class="data row3 col7" >9070</td>
+          <td id="T_9f9cf_row3_col8" class="data row3 col8" >502.09 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_9f9cf_level0_row4" class="row_heading level0 row4" >4</th>
+          <td id="T_9f9cf_row4_col0" class="data row4 col0" >80.1</td>
+          <td id="T_9f9cf_row4_col1" class="data row4 col1" >Y (8g)</td>
+          <td id="T_9f9cf_row4_col2" class="data row4 col2" >Acceleration</td>
+          <td id="T_9f9cf_row4_col3" class="data row4 col3" >g</td>
+          <td id="T_9f9cf_row4_col4" class="data row4 col4" >00:00.0948</td>
+          <td id="T_9f9cf_row4_col5" class="data row4 col5" >00:19.0013</td>
+          <td id="T_9f9cf_row4_col6" class="data row4 col6" >00:18.0064</td>
+          <td id="T_9f9cf_row4_col7" class="data row4 col7" >9070</td>
+          <td id="T_9f9cf_row4_col8" class="data row4 col8" >502.09 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_9f9cf_level0_row5" class="row_heading level0 row5" >5</th>
+          <td id="T_9f9cf_row5_col0" class="data row5 col0" >80.2</td>
+          <td id="T_9f9cf_row5_col1" class="data row5 col1" >Z (8g)</td>
+          <td id="T_9f9cf_row5_col2" class="data row5 col2" >Acceleration</td>
+          <td id="T_9f9cf_row5_col3" class="data row5 col3" >g</td>
+          <td id="T_9f9cf_row5_col4" class="data row5 col4" >00:00.0948</td>
+          <td id="T_9f9cf_row5_col5" class="data row5 col5" >00:19.0013</td>
+          <td id="T_9f9cf_row5_col6" class="data row5 col6" >00:18.0064</td>
+          <td id="T_9f9cf_row5_col7" class="data row5 col7" >9070</td>
+          <td id="T_9f9cf_row5_col8" class="data row5 col8" >502.09 Hz</td>
+        </tr>
+      </tbody>
+    </table>
+
+
+
+
+Measurement types can be combined to retrieve more than one:
+
+.. code:: python3
+
+    get_channel_table(doc, ACCELERATION+TEMPERATURE)
+
+
+
+
+.. raw:: html
+
+    <style type="text/css">
+    </style>
+    <table id="T_68598_">
+      <thead>
+        <tr>
+          <th class="blank level0" >&nbsp;</th>
+          <th class="col_heading level0 col0" >channel</th>
+          <th class="col_heading level0 col1" >name</th>
+          <th class="col_heading level0 col2" >type</th>
+          <th class="col_heading level0 col3" >units</th>
+          <th class="col_heading level0 col4" >start</th>
+          <th class="col_heading level0 col5" >end</th>
+          <th class="col_heading level0 col6" >duration</th>
+          <th class="col_heading level0 col7" >samples</th>
+          <th class="col_heading level0 col8" >rate</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr>
+          <th id="T_68598_level0_row0" class="row_heading level0 row0" >0</th>
+          <td id="T_68598_row0_col0" class="data row0 col0" >32.0</td>
+          <td id="T_68598_row0_col1" class="data row0 col1" >X (16g)</td>
+          <td id="T_68598_row0_col2" class="data row0 col2" >Acceleration</td>
+          <td id="T_68598_row0_col3" class="data row0 col3" >g</td>
+          <td id="T_68598_row0_col4" class="data row0 col4" >00:00.0952</td>
+          <td id="T_68598_row0_col5" class="data row0 col5" >00:19.0012</td>
+          <td id="T_68598_row0_col6" class="data row0 col6" >00:18.0059</td>
+          <td id="T_68598_row0_col7" class="data row0 col7" >7113</td>
+          <td id="T_68598_row0_col8" class="data row0 col8" >393.86 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_68598_level0_row1" class="row_heading level0 row1" >1</th>
+          <td id="T_68598_row1_col0" class="data row1 col0" >32.1</td>
+          <td id="T_68598_row1_col1" class="data row1 col1" >Y (16g)</td>
+          <td id="T_68598_row1_col2" class="data row1 col2" >Acceleration</td>
+          <td id="T_68598_row1_col3" class="data row1 col3" >g</td>
+          <td id="T_68598_row1_col4" class="data row1 col4" >00:00.0952</td>
+          <td id="T_68598_row1_col5" class="data row1 col5" >00:19.0012</td>
+          <td id="T_68598_row1_col6" class="data row1 col6" >00:18.0059</td>
+          <td id="T_68598_row1_col7" class="data row1 col7" >7113</td>
+          <td id="T_68598_row1_col8" class="data row1 col8" >393.86 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_68598_level0_row2" class="row_heading level0 row2" >2</th>
+          <td id="T_68598_row2_col0" class="data row2 col0" >32.2</td>
+          <td id="T_68598_row2_col1" class="data row2 col1" >Z (16g)</td>
+          <td id="T_68598_row2_col2" class="data row2 col2" >Acceleration</td>
+          <td id="T_68598_row2_col3" class="data row2 col3" >g</td>
+          <td id="T_68598_row2_col4" class="data row2 col4" >00:00.0952</td>
+          <td id="T_68598_row2_col5" class="data row2 col5" >00:19.0012</td>
+          <td id="T_68598_row2_col6" class="data row2 col6" >00:18.0059</td>
+          <td id="T_68598_row2_col7" class="data row2 col7" >7113</td>
+          <td id="T_68598_row2_col8" class="data row2 col8" >393.86 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_68598_level0_row3" class="row_heading level0 row3" >3</th>
+          <td id="T_68598_row3_col0" class="data row3 col0" >80.0</td>
+          <td id="T_68598_row3_col1" class="data row3 col1" >X (8g)</td>
+          <td id="T_68598_row3_col2" class="data row3 col2" >Acceleration</td>
+          <td id="T_68598_row3_col3" class="data row3 col3" >g</td>
+          <td id="T_68598_row3_col4" class="data row3 col4" >00:00.0948</td>
+          <td id="T_68598_row3_col5" class="data row3 col5" >00:19.0013</td>
+          <td id="T_68598_row3_col6" class="data row3 col6" >00:18.0064</td>
+          <td id="T_68598_row3_col7" class="data row3 col7" >9070</td>
+          <td id="T_68598_row3_col8" class="data row3 col8" >502.09 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_68598_level0_row4" class="row_heading level0 row4" >4</th>
+          <td id="T_68598_row4_col0" class="data row4 col0" >80.1</td>
+          <td id="T_68598_row4_col1" class="data row4 col1" >Y (8g)</td>
+          <td id="T_68598_row4_col2" class="data row4 col2" >Acceleration</td>
+          <td id="T_68598_row4_col3" class="data row4 col3" >g</td>
+          <td id="T_68598_row4_col4" class="data row4 col4" >00:00.0948</td>
+          <td id="T_68598_row4_col5" class="data row4 col5" >00:19.0013</td>
+          <td id="T_68598_row4_col6" class="data row4 col6" >00:18.0064</td>
+          <td id="T_68598_row4_col7" class="data row4 col7" >9070</td>
+          <td id="T_68598_row4_col8" class="data row4 col8" >502.09 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_68598_level0_row5" class="row_heading level0 row5" >5</th>
+          <td id="T_68598_row5_col0" class="data row5 col0" >80.2</td>
+          <td id="T_68598_row5_col1" class="data row5 col1" >Z (8g)</td>
+          <td id="T_68598_row5_col2" class="data row5 col2" >Acceleration</td>
+          <td id="T_68598_row5_col3" class="data row5 col3" >g</td>
+          <td id="T_68598_row5_col4" class="data row5 col4" >00:00.0948</td>
+          <td id="T_68598_row5_col5" class="data row5 col5" >00:19.0013</td>
+          <td id="T_68598_row5_col6" class="data row5 col6" >00:18.0064</td>
+          <td id="T_68598_row5_col7" class="data row5 col7" >9070</td>
+          <td id="T_68598_row5_col8" class="data row5 col8" >502.09 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_68598_level0_row6" class="row_heading level0 row6" >6</th>
+          <td id="T_68598_row6_col0" class="data row6 col0" >36.1</td>
+          <td id="T_68598_row6_col1" class="data row6 col1" >Pressure/Temperature:01</td>
+          <td id="T_68598_row6_col2" class="data row6 col2" >Temperature</td>
+          <td id="T_68598_row6_col3" class="data row6 col3" >°C</td>
+          <td id="T_68598_row6_col4" class="data row6 col4" >00:00.0945</td>
+          <td id="T_68598_row6_col5" class="data row6 col5" >00:19.0175</td>
+          <td id="T_68598_row6_col6" class="data row6 col6" >00:18.0230</td>
+          <td id="T_68598_row6_col7" class="data row6 col7" >20</td>
+          <td id="T_68598_row6_col8" class="data row6 col8" >1.10 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_68598_level0_row7" class="row_heading level0 row7" >7</th>
+          <td id="T_68598_row7_col0" class="data row7 col0" >59.1</td>
+          <td id="T_68598_row7_col1" class="data row7 col1" >Control Pad Temperature</td>
+          <td id="T_68598_row7_col2" class="data row7 col2" >Temperature</td>
+          <td id="T_68598_row7_col3" class="data row7 col3" >°C</td>
+          <td id="T_68598_row7_col4" class="data row7 col4" >00:00.0979</td>
+          <td id="T_68598_row7_col5" class="data row7 col5" >00:18.0910</td>
+          <td id="T_68598_row7_col6" class="data row7 col6" >00:17.0931</td>
+          <td id="T_68598_row7_col7" class="data row7 col7" >180</td>
+          <td id="T_68598_row7_col8" class="data row7 col8" >10.04 Hz</td>
+        </tr>
+      </tbody>
+    </table>
+
+
+
+
+Information about a specific interval can be retrieved by using the
+``start`` and/or ``end`` arguments. Note that due to different sampling
+rates, the start and end times for slower channels may not precisely
+match the specified ``start`` or ``end``.
+
+.. code:: python3
+
+    get_channel_table(doc, ACCELERATION+TEMPERATURE, start="0:05", end="0:10")
+
+
+
+
+.. raw:: html
+
+    <style type="text/css">
+    </style>
+    <table id="T_6ade9_">
+      <thead>
+        <tr>
+          <th class="blank level0" >&nbsp;</th>
+          <th class="col_heading level0 col0" >channel</th>
+          <th class="col_heading level0 col1" >name</th>
+          <th class="col_heading level0 col2" >type</th>
+          <th class="col_heading level0 col3" >units</th>
+          <th class="col_heading level0 col4" >start</th>
+          <th class="col_heading level0 col5" >end</th>
+          <th class="col_heading level0 col6" >duration</th>
+          <th class="col_heading level0 col7" >samples</th>
+          <th class="col_heading level0 col8" >rate</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr>
+          <th id="T_6ade9_level0_row0" class="row_heading level0 row0" >0</th>
+          <td id="T_6ade9_row0_col0" class="data row0 col0" >32.0</td>
+          <td id="T_6ade9_row0_col1" class="data row0 col1" >X (16g)</td>
+          <td id="T_6ade9_row0_col2" class="data row0 col2" >Acceleration</td>
+          <td id="T_6ade9_row0_col3" class="data row0 col3" >g</td>
+          <td id="T_6ade9_row0_col4" class="data row0 col4" >00:05.0000</td>
+          <td id="T_6ade9_row0_col5" class="data row0 col5" >00:10.0001</td>
+          <td id="T_6ade9_row0_col6" class="data row0 col6" >00:05.0000</td>
+          <td id="T_6ade9_row0_col7" class="data row0 col7" >1969</td>
+          <td id="T_6ade9_row0_col8" class="data row0 col8" >393.75 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_6ade9_level0_row1" class="row_heading level0 row1" >1</th>
+          <td id="T_6ade9_row1_col0" class="data row1 col0" >32.1</td>
+          <td id="T_6ade9_row1_col1" class="data row1 col1" >Y (16g)</td>
+          <td id="T_6ade9_row1_col2" class="data row1 col2" >Acceleration</td>
+          <td id="T_6ade9_row1_col3" class="data row1 col3" >g</td>
+          <td id="T_6ade9_row1_col4" class="data row1 col4" >00:05.0000</td>
+          <td id="T_6ade9_row1_col5" class="data row1 col5" >00:10.0001</td>
+          <td id="T_6ade9_row1_col6" class="data row1 col6" >00:05.0000</td>
+          <td id="T_6ade9_row1_col7" class="data row1 col7" >1969</td>
+          <td id="T_6ade9_row1_col8" class="data row1 col8" >393.75 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_6ade9_level0_row2" class="row_heading level0 row2" >2</th>
+          <td id="T_6ade9_row2_col0" class="data row2 col0" >32.2</td>
+          <td id="T_6ade9_row2_col1" class="data row2 col1" >Z (16g)</td>
+          <td id="T_6ade9_row2_col2" class="data row2 col2" >Acceleration</td>
+          <td id="T_6ade9_row2_col3" class="data row2 col3" >g</td>
+          <td id="T_6ade9_row2_col4" class="data row2 col4" >00:05.0000</td>
+          <td id="T_6ade9_row2_col5" class="data row2 col5" >00:10.0001</td>
+          <td id="T_6ade9_row2_col6" class="data row2 col6" >00:05.0000</td>
+          <td id="T_6ade9_row2_col7" class="data row2 col7" >1969</td>
+          <td id="T_6ade9_row2_col8" class="data row2 col8" >393.75 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_6ade9_level0_row3" class="row_heading level0 row3" >3</th>
+          <td id="T_6ade9_row3_col0" class="data row3 col0" >80.0</td>
+          <td id="T_6ade9_row3_col1" class="data row3 col1" >X (8g)</td>
+          <td id="T_6ade9_row3_col2" class="data row3 col2" >Acceleration</td>
+          <td id="T_6ade9_row3_col3" class="data row3 col3" >g</td>
+          <td id="T_6ade9_row3_col4" class="data row3 col4" >00:05.0000</td>
+          <td id="T_6ade9_row3_col5" class="data row3 col5" >00:10.0001</td>
+          <td id="T_6ade9_row3_col6" class="data row3 col6" >00:05.0000</td>
+          <td id="T_6ade9_row3_col7" class="data row3 col7" >2510</td>
+          <td id="T_6ade9_row3_col8" class="data row3 col8" >501.98 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_6ade9_level0_row4" class="row_heading level0 row4" >4</th>
+          <td id="T_6ade9_row4_col0" class="data row4 col0" >80.1</td>
+          <td id="T_6ade9_row4_col1" class="data row4 col1" >Y (8g)</td>
+          <td id="T_6ade9_row4_col2" class="data row4 col2" >Acceleration</td>
+          <td id="T_6ade9_row4_col3" class="data row4 col3" >g</td>
+          <td id="T_6ade9_row4_col4" class="data row4 col4" >00:05.0000</td>
+          <td id="T_6ade9_row4_col5" class="data row4 col5" >00:10.0001</td>
+          <td id="T_6ade9_row4_col6" class="data row4 col6" >00:05.0000</td>
+          <td id="T_6ade9_row4_col7" class="data row4 col7" >2510</td>
+          <td id="T_6ade9_row4_col8" class="data row4 col8" >501.98 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_6ade9_level0_row5" class="row_heading level0 row5" >5</th>
+          <td id="T_6ade9_row5_col0" class="data row5 col0" >80.2</td>
+          <td id="T_6ade9_row5_col1" class="data row5 col1" >Z (8g)</td>
+          <td id="T_6ade9_row5_col2" class="data row5 col2" >Acceleration</td>
+          <td id="T_6ade9_row5_col3" class="data row5 col3" >g</td>
+          <td id="T_6ade9_row5_col4" class="data row5 col4" >00:05.0000</td>
+          <td id="T_6ade9_row5_col5" class="data row5 col5" >00:10.0001</td>
+          <td id="T_6ade9_row5_col6" class="data row5 col6" >00:05.0000</td>
+          <td id="T_6ade9_row5_col7" class="data row5 col7" >2510</td>
+          <td id="T_6ade9_row5_col8" class="data row5 col8" >501.98 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_6ade9_level0_row6" class="row_heading level0 row6" >6</th>
+          <td id="T_6ade9_row6_col0" class="data row6 col0" >36.1</td>
+          <td id="T_6ade9_row6_col1" class="data row6 col1" >Pressure/Temperature:01</td>
+          <td id="T_6ade9_row6_col2" class="data row6 col2" >Temperature</td>
+          <td id="T_6ade9_row6_col3" class="data row6 col3" >°C</td>
+          <td id="T_6ade9_row6_col4" class="data row6 col4" >00:04.0954</td>
+          <td id="T_6ade9_row6_col5" class="data row6 col5" >00:10.0966</td>
+          <td id="T_6ade9_row6_col6" class="data row6 col6" >00:06.0011</td>
+          <td id="T_6ade9_row6_col7" class="data row6 col7" >6</td>
+          <td id="T_6ade9_row6_col8" class="data row6 col8" >1.00 Hz</td>
+        </tr>
+        <tr>
+          <th id="T_6ade9_level0_row7" class="row_heading level0 row7" >7</th>
+          <td id="T_6ade9_row7_col0" class="data row7 col0" >59.1</td>
+          <td id="T_6ade9_row7_col1" class="data row7 col1" >Control Pad Temperature</td>
+          <td id="T_6ade9_row7_col2" class="data row7 col2" >Temperature</td>
+          <td id="T_6ade9_row7_col3" class="data row7 col3" >°C</td>
+          <td id="T_6ade9_row7_col4" class="data row7 col4" >00:05.0086</td>
+          <td id="T_6ade9_row7_col5" class="data row7 col5" >00:10.0095</td>
+          <td id="T_6ade9_row7_col6" class="data row7 col6" >00:05.0008</td>
+          <td id="T_6ade9_row7_col7" class="data row7 col7" >50</td>
+          <td id="T_6ade9_row7_col8" class="data row7 col8" >9.98 Hz</td>
+        </tr>
+      </tbody>
+    </table>
+
+
+
+
+Extracting intervals: :py:func:`endaq.ide.extract_time()`
+---------------------------------------------------------
+
+A portion of an IDE file can be saved to another, new IDE. The source
+can be a local filename or an opened IDE (from a file or URL).
+
+.. code:: python3
+
+    extract_time("tests/test.ide", "doc_extracted.ide", start="0:05", end="0:10")
+    extract_time(doc1, "doc1_extracted.ide", start="0:05", end="0:10")
+
+Additional sample IDE recording files
+-------------------------------------
+
+Here are a number of example IDE files, which may be used with
+:py:mod:`endaq.ide`:
+
+.. code:: python3
+
+    file_urls = ['https://info.endaq.com/hubfs/data/surgical-instrument.ide',
+                 'https://info.endaq.com/hubfs/data/97c3990f-Drive-Home_70-1616632444.ide',
+                 'https://info.endaq.com/hubfs/data/High-Drop.ide',
+                 'https://info.endaq.com/hubfs/data/HiTest-Shock.ide',
+                 'https://info.endaq.com/hubfs/data/Drive-Home_01.ide',
+                 'https://info.endaq.com/hubfs/data/Tower-of-Terror.ide',
+                 'https://info.endaq.com/hubfs/data/Punching-Bag.ide',
+                 'https://info.endaq.com/hubfs/data/Gun-Stock.ide',
+                 'https://info.endaq.com/hubfs/data/Seat-Base_21.ide',
+                 'https://info.endaq.com/hubfs/data/Seat-Top_09.ide',
+                 'https://info.endaq.com/hubfs/data/Bolted.ide',
+                 'https://info.endaq.com/hubfs/data/Motorcycle-Car-Crash.ide',
+                 'https://info.endaq.com/hubfs/data/train-passing.ide',
+                 'https://info.endaq.com/hubfs/data/baseball.ide',
+                 'https://info.endaq.com/hubfs/data/Clean-Room-VC.ide',
+                 'https://info.endaq.com/hubfs/data/enDAQ_Cropped.ide',
+                 'https://info.endaq.com/hubfs/data/Drive-Home_07.ide',
+                 'https://info.endaq.com/hubfs/data/ford_f150.ide',
+                 'https://info.endaq.com/hubfs/data/Drive-Home.ide',
+                 'https://info.endaq.com/hubfs/data/Mining-Data.ide',
+                 'https://info.endaq.com/hubfs/data/Mide-Airport-Drive-Lexus-Hybrid-Dash-W8.ide']
+
+These can be directly read from endaq.com using :py:func:`endaq.ide.get_doc()`,
+as previously described.
\ No newline at end of file
diff --git a/docs/endaq/plot.rst b/docs/endaq/plot.rst
index 89aea42..776308c 100644
--- a/docs/endaq/plot.rst
+++ b/docs/endaq/plot.rst
@@ -1,6 +1,11 @@
-############
-`endaq.plot`
-############
+##############
+``endaq.plot``
+##############
+
+.. toctree::
+   :maxdepth: 2
+
+   plot_usage
 
 
 `endaq.plot`
diff --git a/docs/endaq/plot_usage.rst b/docs/endaq/plot_usage.rst
new file mode 100644
index 0000000..cb29e28
--- /dev/null
+++ b/docs/endaq/plot_usage.rst
@@ -0,0 +1,67 @@
+``endaq.plot`` Usage Examples
+=============================
+
+For these examples we assume there is a Pandas DataFrame named ``df``
+which has it’s index as time stamps and it’s one column being sensor
+values (e.g. x-axis accleration, or pressure). It also assumes there is
+a Pandas DataFrame ``attribute_df`` which contains all the attribute
+data about various data files. More information can be found about how
+to get this data from enDAQ IDE files in the `endaq-cloud
+readme <https://github.com/MideTechnology/endaq-python/tree/main/endaq/cloud>`__.
+
+.. code:: python
+
+   from endaq.plot import octave_spectrogram, multi_file_plot_attributes, octave_psd_bar_plot
+   from endaq.plot.utilities import set_theme
+
+Setting The Aesthetic Theme
+~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. code:: python
+
+   set_theme(theme='endaq')
+
+Creating Spectrograms With Octave Spaced Frequencies
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. code:: python
+
+   data_df, fig = octave_spectrogram(df, window=.15)
+   fig.show()
+
+.. figure:: https://i.imgur.com/929aszu.png
+   :alt: Spectrogram With Octave Spaced Frequencies
+
+   Spectrogram With Octave Spaced Frequencies
+
+Creating PSD Bar Plots With Octave Spaced Frequencies
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. code:: python
+
+   fig = octave_psd_bar_plot(df, yaxis_title="Magnitude")
+   fig.show()
+
+.. figure:: https://i.imgur.com/ueqcVTQ.png
+   :alt: PSD Bar Plot With Octave Spaced Frequencies
+
+   PSD Bar Plot With Octave Spaced Frequencies
+
+Plot Attributes In Figure With Subplots
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. code:: python
+
+   fig = multi_file_plot_attributes(attribute_df)
+   fig.show()
+
+.. figure:: https://i.imgur.com/5Yy4DN7.png
+   :alt: Attributes Plotted As Subplots
+
+   Attributes Plotted As Subplots
+
+Other Links
+-----------
+
+-  the endaq package - https://github.com/MideTechnology/endaq-python
+-  the enDAQ homepage - https://endaq.com/
diff --git a/endaq/batch/__init__.py b/endaq/batch/__init__.py
index e57f9d8..857cabe 100644
--- a/endaq/batch/__init__.py
+++ b/endaq/batch/__init__.py
@@ -1,1 +1,1 @@
-from .calc import GetDataBuilder
+from .core import GetDataBuilder
diff --git a/endaq/batch/analyzer.py b/endaq/batch/analyzer.py
index fcc625c..343a534 100644
--- a/endaq/batch/analyzer.py
+++ b/endaq/batch/analyzer.py
@@ -1,4 +1,8 @@
-import logging
+from __future__ import annotations
+
+from dataclasses import dataclass
+import typing
+from typing import List, Tuple, Optional
 import sys
 import warnings
 
@@ -7,7 +11,6 @@
 else:
     from backports.cached_property import cached_property
 
-
 import numpy as np
 import pandas as pd
 
@@ -18,113 +21,122 @@
 from endaq.batch.utils import ide_utils
 
 
-class Analyzer:
+MPS2_TO_G = 1 / 9.80665
+MPS_TO_MMPS = 1000
+MPS_TO_UMPS = 10 ** 6
+MPS_TO_KMPH = 3600 / 1e3
+M_TO_MM = 1000
+
+
+@dataclass
+class CalcParams:
     """
-    A class which will run the analyses for the endaq cloud work.  Take the
-    important info from the IDE document so that it can be released from memory.
+    The parameters for configuring the calculation routines in
+    `CalcCache`.
+
+    Each of these parameters is *intentionally* left w/o a default value.
+    Instead, defaults are provided at the function signatures for the
+    `endaq.batch.core` functions. This ensures that data is passed correctly
+    to them from the `CalcParam` object.
     """
 
-    # TODO: This needs to be reworked, it's currently not very efficient.
-    #       -
-    #       The current strategy is to:
-    #         1. Create a list of time indices for each segment, where the
-    #            beginning of the first segment is the earliest data present
-    #            among all channels (not al channels start and end at the same time)
-    #         2. Create a numpy array from each channel, and grab metadata such
-    #            as sampling frequency.
-    #         3. For channels with a low sampling frequency, resample them to
-    #            five times the segment frequency.
-    #         4. Pad each channel with nans so that data exists for each channel
-    #            in each time segment.
-    #         5. Split each of these by the times created in step 1
-    #         6. Lazily run analyses as they're called and cache results.
-    #       -
-    #       The issue is that this is somewhat memory intensive and just doesn't
-    #       need to be. I think we can more efficiently do piecewise evaluation
-    #       and re-use intermediate calculations without saving all of the file
-    #       as an array.
-    #       _
-    #       Proposed strategy:
-    #         1. Same as above, create a list of time indices for each segment,
-    #            where the beginning of the first segment is the earliest data
-    #            present among all channels (not al channels start and end at
-    #            the same time)
-    #         2. For each sensor type:
-    #              a. If the sensor is not present, create a list of NaNs in the
-    #                 correct shape.
-    #              b. For sensors with low sampling rates, use some sort of
-    #                 wrapper or generator that will still lazily load data from
-    #                 the file but will return interpolated data as if it had a
-    #                 higher sample rate (five times the segment frequency)
-    #              c. Iterate through the segments.  If the segment is out of
-    #                 range, skip it and place Nans in each analysis for that
-    #                 channel type.
-    #              d. Do the analyses for that segment.  This will allow us to
-    #                 reuse intermediate calculations, such as the FFT of the
-    #                 acceleration.  This is used to calculate the RMS for the
-    #                 acceleration and its integrals.
-    #
-
-    MPS2_TO_G = 1 / 9.80665
-    MPS_TO_MMPS = 1000
-    MPS_TO_UMPS = 10 ** 6
-    MPS_TO_KMPH = 3600 / 1e3
-    M_TO_MM = 1000
+    accel_highpass_cutoff: Optional[float]
+    accel_integral_tukey_percent: float
+    accel_integral_zero: typing.Literal["start", "mean", "median"]
+    accel_start_time: Optional[np.timedelta64]
+    accel_end_time: Optional[np.timedelta64]
+    accel_start_margin: Optional[np.timedelta64]
+    accel_end_margin: Optional[np.timedelta64]
+    psd_freq_bin_width: float
+    psd_window: str
+    pvss_init_freq: float
+    pvss_bins_per_octave: float
+    vc_init_freq: float
+    vc_bins_per_octave: float
+
+
+class CalcCache:
+    """
+    A wrapper for `idelib.dataset.Dataset` that caches channel data streams as
+    `pandas.Dataset`s.
+    """
 
     PV_NATURAL_FREQS = np.logspace(0, 12, base=2, num=12 * 12 + 1, endpoint=True)
 
-    def __init__(
-        self,
-        doc,
-        *,
-        preferred_chs=[],
-        accel_highpass_cutoff,
-        accel_start_time,
-        accel_end_time,
-        accel_start_margin,
-        accel_end_margin,
-        psd_freq_bin_width,
-        psd_window="hanning",
-        pvss_init_freq,
-        pvss_bins_per_octave,
-        vc_init_freq,
-        vc_bins_per_octave,
-    ):
+    def __init__(self, data, params: CalcParams):
         """
         Copies out the numpy arrays for the highest priority channel for each
         sensor type, and any relevant metadata.  Cuts them into chunks.
         """
-        if accel_start_time is not None and accel_start_margin is not None:
+        if (
+            params.accel_start_time is not None
+            and params.accel_start_margin is not None
+        ):
             raise ValueError(
                 "only one of `accel_start_time` and `accel_start_margin` may be set at once"
             )
-        if accel_end_time is not None and accel_end_margin is not None:
+        if params.accel_end_time is not None and params.accel_end_margin is not None:
             raise ValueError(
                 "only one of `accel_end_time` and `accel_end_margin` may be set at once"
             )
 
-        self._channels = ide_utils.dict_chs_best(
+        self._channels = data
+        self._params = params
+
+    @classmethod
+    def from_ide(cls, dataset, params: CalcParams, preferred_chs: List[int] = []):
+        """
+        Instantiate a new `CalcCache` object from an IDE file.
+        """
+        data = ide_utils.dict_chs_best(
             (
                 (utype, ch_struct)
-                for (utype, ch_struct) in ide_utils.chs_by_utype(doc)
+                for (utype, ch_struct) in ide_utils.chs_by_utype(dataset)
                 if len(ch_struct.eventarray) > 0
             ),
             max_key=lambda x: (x.channel.id in preferred_chs, len(x.eventarray)),
         )
 
-        self._filename = doc.filename
-        self._accelerationFs = None  # gets set in `_accelerationData`
-        self._accel_highpass_cutoff = accel_highpass_cutoff
-        self._accel_start_time = accel_start_time
-        self._accel_end_time = accel_end_time
-        self._accel_start_margin = accel_start_margin
-        self._accel_end_margin = accel_end_margin
-        self._psd_window = psd_window
-        self._psd_freq_bin_width = psd_freq_bin_width
-        self._pvss_init_freq = pvss_init_freq
-        self._pvss_bins_per_octave = pvss_bins_per_octave
-        self._vc_init_freq = vc_init_freq
-        self._vc_bins_per_octave = vc_bins_per_octave
+        return cls(data, params=params)
+
+    @dataclass
+    class InputDataWrapper:
+        data: pd.DataFrame
+        units: Tuple[str, str]
+
+        def to_pandas(self, time_mode="datetime"):
+            expected_index_types = dict(
+                timedelta=(pd.TimedeltaIndex, "TimedeltaIndex"),
+                datetime=(pd.DatetimeIndex, "DatetimeIndex"),
+                seconds=(
+                    (pd.Float64Index, pd.Int64Index, pd.UInt64Index, pd.RangeIndex),
+                    "{Float64/Int64/UInt64/Range}Index",
+                ),
+            )
+            if not isinstance(self.data.index, expected_index_types[time_mode][0]):
+                raise ValueError(
+                    f"expected '{time_mode}' data index to be of type"
+                    f"`{expected_index_types[time_mode][1]}`, "
+                    f"instead found {type(self.data.index)}"
+                )
+
+            self.data.index.name = "timestamp"
+            self.data.columns.name = "axis"
+
+            return self.data
+
+    @classmethod
+    def from_raw_data(
+        cls, data: List[Tuple[pd.DataFrame, Tuple[str, str]]], params: CalcParams
+    ):
+        """
+        Instantiate a new `CalcCache` object from raw DataFrame / metadata pairs.
+        """
+        data = {
+            ide_utils.UTYPE_GROUPS[units[0]]: cls.InputDataWrapper(data, units)
+            for (data, units) in data
+        }
+        return cls(data, params=params)
 
     # ==========================================================================
     # Data Processing, just to make init cleaner
@@ -135,52 +147,49 @@ def _accelerationData(self):
         """Populate the _acceleration* fields, including splitting and extending data."""
         ch_struct = self._channels.get("acc", None)
         if ch_struct is None:
-            logging.warning(f"no acceleration channel in {self._filename}")
+            warnings.warn(f"no acceleration channel in data")
             return pd.DataFrame(
-                np.empty((0, 3), dtype=np.float),
-                index=pd.Series([], name="time"),
+                np.empty((0, 3), dtype=float),
+                index=pd.Series([], dtype="timedelta64[ns]", name="time"),
                 columns=pd.Series(["X", "Y", "Z"], name="axis"),
             )
 
         aUnits = ch_struct.units[1]
         try:
             conversionFactor = {  # core units = m/s^2
-                "g": 1 / self.MPS2_TO_G,
+                "g": 1 / MPS2_TO_G,
                 "m/s\u00b2": 1,
             }[aUnits.lower()]
         except KeyError:
             raise ValueError(f'unknown acceleration channel units "{aUnits}"')
 
-        self._accelerationName = ch_struct.channel.name
-        self._accelerationFs = ch_struct.fs
-
         aData = conversionFactor * ch_struct.to_pandas(time_mode="timedelta")
+        dt = endaq.calc.sample_spacing(aData, convert=None)
 
-        if self._accel_start_margin is not None:
-            margin = int(
-                np.ceil(
-                    ch_struct.fs * self._accel_start_margin / np.timedelta64(1, "s")
-                )
-            )
+        if self._params.accel_start_margin is not None:
+            margin = int(np.ceil(self._params.accel_start_margin / dt))
             aData = aData.iloc[margin:]
-        elif self._accel_start_time is not None:
-            aData = aData.loc[self._accel_start_time :]
-        if self._accel_end_margin is not None:
-            margin = int(
-                np.ceil(ch_struct.fs * self._accel_end_margin / np.timedelta64(1, "s"))
-            )
+        elif self._params.accel_start_time is not None:
+            aData = aData.loc[self._params.accel_start_time :]
+        if self._params.accel_end_margin is not None:
+            margin = int(np.ceil(self._params.accel_end_margin / dt))
             aData = aData.iloc[: (-margin or None)]
-        elif self._accel_end_time is not None:
-            aData = aData.loc[: self._accel_end_time]
+        elif self._params.accel_end_time is not None:
+            aData = aData.loc[: self._params.accel_end_time]
 
-        aData = filters.butterworth(aData, low_cutoff=self._accel_highpass_cutoff)
+        aData = filters.butterworth(
+            aData, low_cutoff=self._params.accel_highpass_cutoff
+        )
 
         assert isinstance(aData, pd.DataFrame)
         return aData
 
     @cached_property
     def _accelerationResultantData(self):
-        return self._accelerationData.apply(stats.L2_norm, axis="columns").to_frame()
+        return pd.Series(
+            stats.L2_norm(self._accelerationData.to_numpy(), axis=1),
+            index=self._accelerationData.index,
+        ).to_frame()
 
     @cached_property
     def _microphoneData(self):
@@ -188,8 +197,8 @@ def _microphoneData(self):
         ch_struct = self._channels.get("mic", None)
         if ch_struct is None:
             return pd.DataFrame(
-                np.empty((0, 1), dtype=np.float),
-                index=pd.Series([], name="time"),
+                np.empty((0, 1), dtype=float),
+                index=pd.Series([], dtype="timedelta64[ns]", name="time"),
                 columns=pd.Series(["mic"], name="axis"),
             )
 
@@ -197,11 +206,7 @@ def _microphoneData(self):
         if units.lower() != "a":
             raise ValueError(f'unknown microphone channel units "{units}"')
 
-        self._micName = ch_struct.channel.name
-        self._micFs = ch_struct.fs
-        data = ch_struct.to_pandas(time_mode="timedelta")
-
-        return data
+        return ch_struct.to_pandas(time_mode="timedelta")
 
     @cached_property
     def _velocityData(self):
@@ -209,13 +214,27 @@ def _velocityData(self):
         if aData.size == 0:
             return aData
 
-        if not self._accel_highpass_cutoff:
-            logging.warning(
+        if not self._params.accel_highpass_cutoff:
+            warnings.warn(
                 "no highpass filter used before integration; "
                 "velocity calculation may be unstable"
             )
 
-        return integrate._integrate(aData)
+        return integrate._integrate(
+            filters.butterworth(
+                aData,
+                low_cutoff=self._params.accel_highpass_cutoff,
+                tukey_percent=self._params.accel_integral_tukey_percent,
+            ),
+            zero=self._params.accel_integral_zero,
+        )
+
+    @cached_property
+    def _velocityResultantData(self):
+        return pd.Series(
+            stats.L2_norm(self._velocityData.to_numpy(), axis=1),
+            index=self._velocityData.index,
+        ).to_frame()
 
     @cached_property
     def _displacementData(self):
@@ -223,30 +242,44 @@ def _displacementData(self):
         if vData.size == 0:
             return vData
 
-        if not self._accel_highpass_cutoff:
-            logging.warning(
+        if not self._params.accel_highpass_cutoff:
+            warnings.warn(
                 "no highpass filter used before integration; "
                 "displacement calculation may be unstable"
             )
 
-        return integrate._integrate(vData)
+        return integrate._integrate(
+            filters.butterworth(
+                vData,
+                low_cutoff=self._params.accel_highpass_cutoff,
+                tukey_percent=self._params.accel_integral_tukey_percent,
+            ),
+            zero=self._params.accel_integral_zero,
+        )
+
+    @cached_property
+    def _displacementResultantData(self):
+        return pd.Series(
+            stats.L2_norm(self._displacementData.to_numpy(), axis=1),
+            index=self._displacementData.index,
+        ).to_frame()
 
     @cached_property
     def _PVSSData(self):
         aData = self._accelerationData
         if aData.size == 0:
-            pvss = aData[:]
+            pvss = aData.copy()
             pvss.index.name = "frequency (Hz)"
             return pvss
 
         freqs = endaq.calc.logfreqs(
-            aData, self._pvss_init_freq, self._pvss_bins_per_octave
+            aData, self._params.pvss_init_freq, self._params.pvss_bins_per_octave
         )
         freqs = freqs[
-            (freqs >= self._accelerationFs / self._accelerationData.shape[0])
+            (freqs >= 1 / (endaq.calc.sample_spacing(aData) * aData.shape[0]))
         ]
         pv = shock.shock_spectrum(
-            self._accelerationData,
+            aData,
             freqs,
             damp=0.05,
             mode="pvss",
@@ -258,18 +291,18 @@ def _PVSSData(self):
     def _PVSSResultantData(self):
         aData = self._accelerationData
         if aData.size == 0:
-            pvss = self._accelerationResultantData[:]
+            pvss = self._accelerationResultantData.copy()
             pvss.index.name = "frequency (Hz)"
             return pvss
 
         freqs = endaq.calc.logfreqs(
-            aData, self._pvss_init_freq, self._pvss_bins_per_octave
+            aData, self._params.pvss_init_freq, self._params.pvss_bins_per_octave
         )
         freqs = freqs[
-            (freqs >= self._accelerationFs / self._accelerationData.shape[0])
+            (freqs >= 1 / (endaq.calc.sample_spacing(aData) * aData.shape[0]))
         ]
         pv = shock.shock_spectrum(
-            self._accelerationData,
+            aData,
             freqs,
             damp=0.05,
             mode="pvss",
@@ -282,30 +315,34 @@ def _PVSSResultantData(self):
     def _PSDData(self):
         aData = self._accelerationData
         if aData.size == 0:
-            psdData = aData[:]
+            psdData = aData.copy()
             psdData.index.name = "frequency (Hz)"
             return psdData
 
         return endaq.calc.psd.welch(
             aData,
-            bin_width=self._psd_freq_bin_width,
-            window=self._psd_window,
+            bin_width=self._params.psd_freq_bin_width,
+            window=self._params.psd_window,
             average="median",
         )
 
+    @cached_property
+    def _PSDResultantData(self):
+        return self._PSDData.sum(axis="columns").to_frame()
+
     @cached_property
     def _VCCurveData(self):
         """Calculate Vibration Criteria (VC) Curves for the accelerometer."""
         psdData = self._PSDData
         if psdData.size == 0:
-            vcData = psdData[:]
+            vcData = psdData.copy()
             vcData.index.name = "frequency (Hz)"
             return vcData
 
         return psd.vc_curves(
             self._PSDData,
-            fstart=self._vc_init_freq,
-            octave_bins=self._vc_bins_per_octave,
+            fstart=self._params.vc_init_freq,
+            octave_bins=self._params.vc_bins_per_octave,
         )
 
     @cached_property
@@ -314,8 +351,8 @@ def _pressureData(self):
         ch_struct = self._channels.get("pre", None)
         if ch_struct is None:
             return pd.DataFrame(
-                np.empty((0, 1), dtype=np.float),
-                index=pd.Series([], name="time"),
+                np.empty((0, 1), dtype=float),
+                index=pd.Series([], dtype="timedelta64[ns]", name="time"),
                 columns=pd.Series(["Pressure"], name="axis"),
             )
 
@@ -329,11 +366,7 @@ def _pressureData(self):
         except KeyError:
             raise ValueError(f'unknown pressure channel units "{units}"')
 
-        self._preName = ch_struct.channel.name
-        self._preFs = ch_struct.fs
-        data = conversionFactor * ch_struct.to_pandas(time_mode="timedelta")
-
-        return data
+        return conversionFactor * ch_struct.to_pandas(time_mode="timedelta")
 
     @cached_property
     def _temperatureData(self):
@@ -341,8 +374,8 @@ def _temperatureData(self):
         ch_struct = self._channels.get("tmp", None)
         if ch_struct is None:
             return pd.DataFrame(
-                np.empty((0, 1), dtype=np.float),
-                index=pd.Series([], name="time"),
+                np.empty((0, 1), dtype=float),
+                index=pd.Series([], dtype="timedelta64[ns]", name="time"),
                 columns=pd.Series(["Temperature"], name="axis"),
             )
 
@@ -356,37 +389,30 @@ def _temperatureData(self):
         except KeyError:
             raise ValueError(f'unknown temperature channel units "{units}"')
 
-        self._tmpName = ch_struct.channel.name
-        self._tmpFs = ch_struct.fs
-        data = conversionOffset + conversionFactor * ch_struct.to_pandas(
+        return conversionOffset + conversionFactor * ch_struct.to_pandas(
             time_mode="timedelta"
         )
 
-        return data
-
     @cached_property
     def _gyroscopeData(self):
         """Populate the _gyro* fields, including splitting and extending data."""
         ch_struct = self._channels.get("gyr", None)
         if ch_struct is None:
             return pd.DataFrame(
-                np.empty((0, 3), dtype=np.float),
-                index=pd.Series([], name="time"),
+                np.empty((0, 3), dtype=float),
+                index=pd.Series([], dtype="timedelta64[ns]", name="time"),
                 columns=pd.Series(["X", "Y", "Z"], name="axis"),
             )
 
-        self._gyroName = ch_struct.channel.name
-        self._gyroFs = ch_struct.fs
-
+        data = ch_struct.to_pandas(time_mode="timedelta")
+        dt = endaq.calc.sample_spacing(data, convert="to_seconds")
         units = ch_struct.units[1]
         if units.lower() == "q":
-            quat_df = ch_struct.to_pandas(time_mode="timedelta")
-            quat_raw = quat_df[["W", "X", "Y", "Z"]].to_numpy()
+            quat_raw = data[["W", "X", "Y", "Z"]].to_numpy()
 
             data = pd.DataFrame(
-                (180 / np.pi)
-                * quat.quat_to_angvel(quat_raw, 1 / self._gyroFs, qaxis=1),
-                index=quat_df.index,
+                (180 / np.pi) * quat.quat_to_angvel(quat_raw, dt, qaxis=1),
+                index=data.index,
                 columns=pd.Series(["X", "Y", "Z"], name="axis"),
             )
 
@@ -409,11 +435,9 @@ def strip_invalid_prefix(data, prefix_len):
                 return data
 
             data = strip_invalid_prefix(
-                data, prefix_len=max(4, int(np.ceil(0.25 * self._gyroFs)))
+                data, prefix_len=max(4, int(np.ceil(0.25 / dt)))
             )
-        elif units.lower() in ("dps", "deg/s"):
-            data = ch_struct.to_pandas(time_mode="timedelta")
-        else:
+        elif units.lower() not in ("dps", "deg/s"):
             raise ValueError(f'unknown gyroscope channel units "{units}"')
 
         return data
@@ -423,8 +447,8 @@ def _gpsPositionData(self):
         ch_struct = self._channels.get("gps", None)
         if ch_struct is None:
             return pd.DataFrame(
-                np.empty((0, 2), dtype=np.float),
-                index=pd.Series([], name="time"),
+                np.empty((0, 2), dtype=float),
+                index=pd.Series([], dtype="timedelta64[ns]", name="time"),
                 columns=pd.Series(["Latitude", "Longitude"], name="axis"),
             )
 
@@ -432,21 +456,16 @@ def _gpsPositionData(self):
         if units.lower() != "degrees":
             raise ValueError(f'unknown GPS position channel units "{units}"')
 
-        data = ch_struct.to_pandas(time_mode="timedelta")
-
-        self._gpsName = ch_struct.channel.name
-        self._gpsFs = ch_struct.fs
         # resampling destroys last values -> no resampling
-
-        return data
+        return ch_struct.to_pandas(time_mode="timedelta")
 
     @cached_property
     def _gpsSpeedData(self):
         ch_struct = self._channels.get("spd", None)
         if ch_struct is None:
             return pd.DataFrame(
-                np.empty((0, 1), dtype=np.float),
-                index=pd.Series([], name="time"),
+                np.empty((0, 1), dtype=float),
+                index=pd.Series([], dtype="timedelta64[ns]", name="time"),
                 columns=pd.Series(["GPS Speed"], name="axis"),
             )
 
@@ -454,12 +473,7 @@ def _gpsSpeedData(self):
         if units != "m/s":
             raise ValueError(f'unknown GPS ground speed channel units "{units}"')
 
-        data = self.MPS_TO_KMPH * ch_struct.to_pandas(time_mode="timedelta")
-
-        self._gpsSpeedName = ch_struct.channel.name
-        self._gpsSpeedFs = ch_struct.fs
-
-        return data
+        return MPS_TO_KMPH * ch_struct.to_pandas(time_mode="timedelta")
 
     # ==========================================================================
     # Analyses
@@ -476,7 +490,7 @@ def accRMSFull(self):
 
         rms["Resultant"] = stats.L2_norm(rms.to_numpy())
         rms.name = "RMS Acceleration"
-        return self.MPS2_TO_G * rms
+        return MPS2_TO_G * rms
 
     @cached_property
     def velRMSFull(self):
@@ -489,7 +503,7 @@ def velRMSFull(self):
 
         rms["Resultant"] = stats.L2_norm(rms.to_numpy())
         rms.name = "RMS Velocity"
-        return self.MPS_TO_MMPS * rms
+        return MPS_TO_MMPS * rms
 
     @cached_property
     def disRMSFull(self):
@@ -502,7 +516,7 @@ def disRMSFull(self):
 
         rms["Resultant"] = stats.L2_norm(rms.to_numpy())
         rms.name = "RMS Displacement"
-        return self.M_TO_MM * rms
+        return M_TO_MM * rms
 
     @cached_property
     def accPeakFull(self):
@@ -511,7 +525,7 @@ def accPeakFull(self):
         max_abs_res = self._accelerationResultantData.max(axis="rows")
         max_abs["Resultant"] = max_abs_res.iloc[0]
         max_abs.name = "Peak Absolute Acceleration"
-        return self.MPS2_TO_G * max_abs
+        return MPS2_TO_G * max_abs
 
     @cached_property
     def pseudoVelPeakFull(self):
@@ -520,7 +534,7 @@ def pseudoVelPeakFull(self):
         max_pv_res = self._PVSSResultantData.max(axis="rows")
         max_pv["Resultant"] = max_pv_res.iloc[0]
         max_pv.name = "Peak Pseudo Velocity Shock Spectrum"
-        return self.MPS2_TO_G * max_pv
+        return MPS2_TO_G * max_pv
 
     @cached_property
     def gpsLocFull(self):
diff --git a/endaq/batch/calc.py b/endaq/batch/core.py
similarity index 83%
rename from endaq/batch/calc.py
rename to endaq/batch/core.py
index 1897eed..427cd27 100644
--- a/endaq/batch/calc.py
+++ b/endaq/batch/core.py
@@ -1,7 +1,9 @@
 from __future__ import annotations
 
+from typing import List
+
 from functools import partial
-import logging
+import warnings
 import os
 
 import numpy as np
@@ -11,7 +13,7 @@
 from endaq.calc import stats as calc_stats
 from endaq.calc import psd as calc_psd
 
-from endaq.batch.analyzer import Analyzer
+import endaq.batch.analyzer
 
 
 def _make_meta(dataset):
@@ -28,18 +30,17 @@ def _make_meta(dataset):
     )
 
 
-def _make_psd(analyzer, fstart=None, bins_per_octave=None):
+def _make_psd(ch_data_cache, fstart=None, bins_per_octave=None):
     """
     Format the PSD of the main accelerometer channel into a pandas object.
 
     The PSD is scaled to units of g^2/Hz (g := gravity = 9.80665 meters per
     square second).
     """
-    accel_ch = analyzer._channels.get("acc", None)
-    if accel_ch is None:
+    df_psd = ch_data_cache._PSDData
+    if df_psd.size == 0:
         return None
 
-    df_psd = analyzer._PSDData
     if bins_per_octave is not None:
         df_psd = calc_psd.to_octave(
             df_psd,
@@ -49,29 +50,28 @@ def _make_psd(analyzer, fstart=None, bins_per_octave=None):
         )
 
     df_psd["Resultant"] = np.sum(df_psd.to_numpy(), axis=1)
-    df_psd = df_psd * analyzer.MPS2_TO_G ** 2  # (m/s^2)^2/Hz -> g^2/Hz
+    df_psd = df_psd * endaq.batch.analyzer.MPS2_TO_G ** 2  # (m/s^2)^2/Hz -> g^2/Hz
 
     return df_psd.stack(level="axis").reorder_levels(["axis", "frequency (Hz)"])
 
 
-def _make_pvss(analyzer):
+def _make_pvss(ch_data_cache):
     """
     Format the PVSS of the main accelerometer channel into a pandas object.
 
     The PVSS is scaled to units of mm/sec.
     """
-    accel_ch = analyzer._channels.get("acc", None)
-    if accel_ch is None:
+    df_pvss = ch_data_cache._PVSSData
+    if df_pvss.size == 0:
         return None
 
-    df_pvss = analyzer._PVSSData
-    df_pvss["Resultant"] = analyzer._PVSSResultantData
-    df_pvss = df_pvss * analyzer.MPS_TO_MMPS
+    df_pvss["Resultant"] = ch_data_cache._PVSSResultantData
+    df_pvss = df_pvss * endaq.batch.analyzer.MPS_TO_MMPS
 
     return df_pvss.stack(level="axis").reorder_levels(["axis", "frequency (Hz)"])
 
 
-def _make_metrics(analyzer):
+def _make_metrics(ch_data_cache):
     """
     Format the channel metrics of a recording into a pandas object.
 
@@ -88,17 +88,17 @@ def _make_metrics(analyzer):
     """
     df = pd.concat(
         [
-            analyzer.accRMSFull,
-            analyzer.velRMSFull,
-            analyzer.disRMSFull,
-            analyzer.accPeakFull,
-            analyzer.pseudoVelPeakFull,
-            analyzer.gpsLocFull,
-            analyzer.gpsSpeedFull,
-            analyzer.gyroRMSFull,
-            analyzer.micRMSFull,
-            analyzer.tempFull,
-            analyzer.pressFull,
+            ch_data_cache.accRMSFull,
+            ch_data_cache.velRMSFull,
+            ch_data_cache.disRMSFull,
+            ch_data_cache.accPeakFull,
+            ch_data_cache.pseudoVelPeakFull,
+            ch_data_cache.gpsLocFull,
+            ch_data_cache.gpsSpeedFull,
+            ch_data_cache.gyroRMSFull,
+            ch_data_cache.micRMSFull,
+            ch_data_cache.tempFull,
+            ch_data_cache.pressFull,
         ],
         axis="columns",
     )
@@ -110,7 +110,7 @@ def _make_metrics(analyzer):
     return series
 
 
-def _make_peak_windows(analyzer, margin_len):
+def _make_peak_windows(ch_data_cache, margin_len):
     """
     Store windows of the main accelerometer channel about its peaks in a pandas
     object.
@@ -118,26 +118,24 @@ def _make_peak_windows(analyzer, margin_len):
     The acceleration is scaled to units of g (gravity = 9.80665 meters per
     square second).
     """
-    accel_ch = analyzer._channels.get("acc", None)
-    if accel_ch is None:
+    df_accel = ch_data_cache._accelerationData.copy()
+    df_accel["Resultant"] = ch_data_cache._accelerationResultantData
+    df_accel = endaq.batch.analyzer.MPS2_TO_G * df_accel
+    if df_accel.size == 0:
         return None
 
-    data = analyzer._accelerationData[:]
-    data["Resultant"] = analyzer._accelerationResultantData
-    data = analyzer.MPS2_TO_G * data
-
-    dt = 1 / analyzer._accelerationFs
+    dt = endaq.calc.sample_spacing(df_accel)
 
-    data_noidx = data.reset_index(drop=True)
+    data_noidx = df_accel.reset_index(drop=True)
     peak_indices = data_noidx.abs().idxmax(axis="rows")
     aligned_peak_data = pd.concat(
         [
             pd.Series(
-                data[col].to_numpy(),
+                df_accel[col].to_numpy(),
                 index=(data_noidx.index - peak_indices[col]),
                 name=col,
             )
-            for col in data.columns
+            for col in df_accel.columns
         ],
         axis="columns",
     )
@@ -149,7 +147,7 @@ def _make_peak_windows(analyzer, margin_len):
         pd.DataFrame(
             {
                 "axis": aligned_peak_data.columns.values,
-                "peak time": data.index[peak_indices],
+                "peak time": df_accel.index[peak_indices],
             }
         )
     )
@@ -164,16 +162,16 @@ def _make_peak_windows(analyzer, margin_len):
     return result
 
 
-def _make_vc_curves(analyzer):
+def _make_vc_curves(ch_data_cache):
     """
     Format the VC curves of the main accelerometer channel into a pandas object.
     """
-    accel_ch = analyzer._channels.get("acc", None)
-    if accel_ch is None:
-        return None
-
-    df_vc = analyzer._VCCurveData * analyzer.MPS_TO_UMPS  # (m/s) -> (μm/s)
+    df_vc = (
+        ch_data_cache._VCCurveData * endaq.batch.analyzer.MPS_TO_UMPS
+    )  # (m/s) -> (μm/s)
     df_vc["Resultant"] = calc_stats.L2_norm(df_vc.to_numpy(), axis=1)
+    if df_vc.size == 0:
+        return None
 
     return df_vc.stack(level="axis").reorder_levels(["axis", "frequency (Hz)"])
 
@@ -196,7 +194,7 @@ class GetDataBuilder:
 
     - *execution functions* - these functions take recording files as parameters,
       perform the configured calculations on the data therein, and return the
-      calculated data as pandas objects.
+      calculated data as a `OutputStruct` object that wraps pandas objects.
 
       This includes the functions ``_get_data`` & ``aggregate_data``, which
       operates on one & multiple file(s), respectively.
@@ -228,6 +226,8 @@ def __init__(
         accel_end_time=None,
         accel_start_margin=None,
         accel_end_margin=None,
+        accel_integral_tukey_percent=0,
+        accel_integral_zero="start",
     ):
         """
         :param preferred_chs: a sequence of channels; each channel listed is
@@ -247,6 +247,13 @@ def __init__(
         :param accel_end_margin: the numper of samples after which to reject
             recording data; cannot be used in conjunction with
             `accel_end_time`
+        :param accel_integral_tukey_percent: the alpha parameter of a tukey
+            window applied to the acceleration before integrating into
+            velocity & displacement; see the `tukey_percent` parameter in
+            ``endaq.calc.integrate.integrals`` for details
+        :param accel_integral_zero: the output quantity driven to zero when
+            integrating the acceleration into velocity & displacement; see the
+            `zero` parameter in ``endaq.calc.integrate.integrals`` for details
         """
         if accel_start_time is not None and accel_start_margin is not None:
             raise ValueError(
@@ -259,16 +266,18 @@ def __init__(
 
         self._metrics_queue = {}  # dict maintains insertion order, unlike set
 
-        self._analyzer_kwargs = dict(
-            preferred_chs=preferred_chs,
+        self._ch_data_cache_kwargs = dict(
             accel_highpass_cutoff=accel_highpass_cutoff,
             accel_start_time=accel_start_time,
             accel_end_time=accel_end_time,
             accel_start_margin=accel_start_margin,
             accel_end_margin=accel_end_margin,
+            accel_integral_tukey_percent=accel_integral_tukey_percent,
+            accel_integral_zero=accel_integral_zero,
         )
+        self._preferred_chs = preferred_chs
 
-        # Even unused parameters MUST be set; used to instantiate `Analyzer` in `_get_data`
+        # Even unused parameters MUST be set; used to instantiate `CalcCache` in `_get_data`
         self._psd_freq_bin_width = None
         self._psd_freq_start_octave = None
         self._psd_bins_per_octave = None
@@ -386,15 +395,18 @@ def _get_data(self, filename):
 
         data = {}
         with endaq.ide.get_doc(filename) as ds:
-            analyzer = Analyzer(
+            ch_data_cache = endaq.batch.analyzer.CalcCache.from_ide(
                 ds,
-                **self._analyzer_kwargs,
-                psd_window=self._psd_window,
-                psd_freq_bin_width=self._psd_freq_bin_width,
-                pvss_init_freq=self._pvss_init_freq,
-                pvss_bins_per_octave=self._pvss_bins_per_octave,
-                vc_init_freq=self._vc_init_freq,
-                vc_bins_per_octave=self._vc_bins_per_octave,
+                endaq.batch.analyzer.CalcParams(
+                    **self._ch_data_cache_kwargs,
+                    psd_window=self._psd_window,
+                    psd_freq_bin_width=self._psd_freq_bin_width,
+                    pvss_init_freq=self._pvss_init_freq,
+                    pvss_bins_per_octave=self._pvss_bins_per_octave,
+                    vc_init_freq=self._vc_init_freq,
+                    vc_bins_per_octave=self._vc_bins_per_octave,
+                ),
+                preferred_chs=self._preferred_chs,
             )
 
             data["meta"] = _make_meta(ds)
@@ -414,7 +426,7 @@ def _get_data(self, filename):
                 vc_curves=_make_vc_curves,
             )
             for output_type in self._metrics_queue.keys():
-                data[output_type] = funcs[output_type](analyzer)
+                data[output_type] = funcs[output_type](ch_data_cache)
 
         return data
 
@@ -476,7 +488,7 @@ class OutputStruct:
     """
 
     def __init__(self, data):
-        self.dataframes = data
+        self.dataframes: List[pd.DataFrame] = data
 
     def to_csv_folder(self, folder_path):
         """
@@ -535,7 +547,7 @@ def to_html_plots(self, folder_path=None, show=False):
                 fig.update_layout(title="Pseudo Velocity Shock Spectrum (PVSS)")
 
             elif k == "metrics":
-                logging.warning("HTML plot for metrics not currently implemented")
+                warnings.warn("HTML plot for metrics not currently implemented")
                 continue
 
             elif k == "peaks":
diff --git a/endaq/calc/filters.py b/endaq/calc/filters.py
index 867ccf6..62e384a 100644
--- a/endaq/calc/filters.py
+++ b/endaq/calc/filters.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from typing import Optional, Union, Tuple
+import functools
 
 import pandas as pd
 import scipy.signal
@@ -13,6 +14,7 @@ def butterworth(
     low_cutoff: Optional[float] = 1.0,
     high_cutoff: Optional[float] = None,
     half_order: int = 3,
+    tukey_percent: float = 0.0,
 ) -> pd.DataFrame:
     """
     Apply a lowpass and/or a highpass Butterworth filter to an array.
@@ -28,12 +30,17 @@ def butterworth(
         this value are rejected, and frequencies below this value are preserved
     :param half_order: half of the order of the filter; higher orders provide
         more aggressive stopband reduction
+    :param tukey_percent: the alpha parameter of a preconditioning Tukey filter;
+        if 0 (default), no filter is applied
     :return: the filtered data
 
     .. seealso::
 
-        `SciPy Butterworth filter design <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.butter.html>`_
-        Documentation for the butterworth filter design function.
+        - `SciPy Butterworth filter design <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.butter.html>`_
+          Documentation for the butterworth filter design function.
+
+        - `SciPy Tukey window <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.windows.tukey.html>`_
+          Documentation for the Tukey window function used in preprocessing.
     """
     cutoff_freqs: Union[float, Tuple[float, float]]
     filter_type: str
@@ -48,17 +55,22 @@ def butterworth(
         cutoff_freqs = high_cutoff
         filter_type = "lowpass"
     else:
-        return df
+        filter_type = ""
+
+    if filter_type:
+        dt = utils.sample_spacing(df)
 
-    dt = utils.sample_spacing(df)
+        sos_coeffs = scipy.signal.butter(
+            N=half_order,
+            Wn=cutoff_freqs,
+            btype=filter_type,
+            fs=1 / dt,
+            output="sos",
+        )
+        df = df.apply(functools.partial(scipy.signal.sosfiltfilt, sos_coeffs), axis=0)
 
-    sos_coeffs = scipy.signal.butter(
-        N=half_order,
-        Wn=cutoff_freqs,
-        btype=filter_type,
-        fs=1 / dt,
-        output="sos",
-    )
-    array = scipy.signal.sosfiltfilt(sos_coeffs, df.to_numpy(), axis=0)
+    if tukey_percent > 0:
+        tukey_window = scipy.signal.windows.tukey(len(df.index), alpha=tukey_percent)
+        df = df.mul(tukey_window, axis="rows")
 
-    return pd.DataFrame(array, index=df.index, columns=df.columns)
+    return df
diff --git a/endaq/calc/integrate.py b/endaq/calc/integrate.py
index afe7759..30daf39 100644
--- a/endaq/calc/integrate.py
+++ b/endaq/calc/integrate.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
-from typing import List, Optional, Iterable
+import typing
+from typing import List, Optional, Iterable, Union, Callable
 import functools
 
 import numpy as np
@@ -10,7 +11,10 @@
 from endaq.calc import filters, utils
 
 
-def _integrate(df: pd.DataFrame) -> pd.DataFrame:
+def _integrate(
+    df: pd.DataFrame,
+    zero: Union[typing.Literal["start", "mean", "median"], Callable] = "start",
+) -> pd.DataFrame:
     """Integrate data over an axis."""
     dt = utils.sample_spacing(df)
 
@@ -21,84 +25,92 @@ def _integrate(df: pd.DataFrame) -> pd.DataFrame:
     )
     # In lieu of explicit initial offset, set integration bias to remove mean
     # -> avoids trend artifacts after successive integrations
-    result = result - result.to_numpy().mean(axis=0, keepdims=True)
+    if zero == "start":
+        return result
+
+    if zero in ("mean", "median"):
+        zero = dict(mean=np.mean, median=np.median)[zero]
 
-    return result
+    return result - result.apply(zero, axis="index", raw=True)
 
 
 def iter_integrals(
     df: pd.DataFrame,
+    zero: Union[typing.Literal["start", "mean", "median"], Callable] = "start",
     highpass_cutoff: Optional[float] = None,
-    filter_half_order: int = 3,
-    tukey_percent: float = 0,
+    tukey_percent: float = 0.0,
 ) -> Iterable[pd.DataFrame]:
     """
     Iterate over conditioned integrals of the given original data.
 
     :param df: the input data
+    :param zero: the output quantity driven to zero by the integration constant;
+        `"start"` (default) chooses an integration constant of ``-output[0]``,
+        `"mean"` chooses ``-np.mean(output)`` & `"median"` chooses
+        ``-np.median(output)``
     :param highpass_cutoff: the cutoff frequency of a preconditioning highpass
         filter; if None, no filter is applied
-    :param filter_half_order: the half-order of the preconditioning highpass
-        filter, if used
-    :param tukey_percent: the alpha parameter of a preconditioning tukey filter;
-        if 0, no filter is applied
+    :param tukey_percent: the alpha parameter of a preconditioning Tukey filter;
+        if 0 (default), no filter is applied
     :return: an iterable over the data's successive integrals; the first item
         is the preconditioned input data
 
     .. seealso::
 
-        `SciPy trapezoid integration <https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.cumulative_trapezoid.html>`_
-        Documentation for the integration function used internally.
+        - `SciPy trapezoid integration <https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.cumulative_trapezoid.html>`_
+          Documentation for the integration function used internally.
 
-        `SciPy Butterworth filter design <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.butter.html>`_
-        Documentation for the butterworth filter design function used in
-        preprocessing.
+        - `SciPy Butterworth filter design <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.butter.html>`_
+          Documentation for the butterworth filter design function used in
+          preprocessing.
 
-        `SciPy Tukey window <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.windows.tukey.html>`_
-        Documentation for the Tukey window function used in preprocessing.
+        - `SciPy Tukey window <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.windows.tukey.html>`_
+          Documentation for the Tukey window function used in preprocessing.
     """
-    df = filters.butterworth(
-        df,
-        half_order=filter_half_order,
-        low_cutoff=highpass_cutoff,
-        high_cutoff=None,
-    )
-
-    if tukey_percent > 0:
-        tukey_window = scipy.signal.windows.tukey(len(df.index), alpha=tukey_percent)
-        df = df.mul(tukey_window, axis="rows")
-
     while True:
         yield df.copy()  # otherwise, edits to the yielded item would alter the results
-        df = _integrate(df)
+        df = filters.butterworth(
+            df,
+            low_cutoff=highpass_cutoff,
+            high_cutoff=None,
+            tukey_percent=tukey_percent,
+        )
+        df = _integrate(df, zero)
 
 
 def integrals(
     df: pd.DataFrame,
     n: int = 1,
+    zero: Union[typing.Literal["start", "mean", "median"], Callable] = "start",
     highpass_cutoff: Optional[float] = None,
-    tukey_percent: float = 0,
+    tukey_percent: float = 0.0,
 ) -> List[pd.DataFrame]:
     """
     Calculate `n` integrations of the given data.
 
     :param df: the data to integrate, indexed with timestamps
-    :param n: the number of integrals to calculate
+    :param n: the number of integrals to calculate; defaults to `1`
+    :param zero: the output quantity driven to zero by the integration constant;
+        `"start"` (default) chooses an integration constant of ``-output[0]``,
+        `"mean"` chooses ``-np.mean(output)`` & `"median"` chooses
+        ``-np.median(output)``
     :param highpass_cutoff: the cutoff frequency for the initial highpass filter;
         this is used to remove artifacts caused by DC trends
+    :param tukey_percent: the alpha parameter of a preconditioning Tukey filter;
+        if 0 (default), no filter is applied
     :return: a length `n+1` list of the kth-order integrals from 0 to n (inclusive)
 
     .. seealso::
 
-        `SciPy trapezoid integration <https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.cumulative_trapezoid.html>`_
-        Documentation for the integration function used internally.
+        - `SciPy trapezoid integration <https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.cumulative_trapezoid.html>`_
+          Documentation for the integration function used internally.
 
-        `SciPy Butterworth filter design <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.butter.html>`_
-        Documentation for the butterworth filter design function used in
-        preprocessing.
+        - `SciPy Butterworth filter design <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.butter.html>`_
+          Documentation for the butterworth filter design function used in
+          preprocessing.
 
-        `SciPy Tukey window <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.windows.tukey.html>`_
-        Documentation for the Tukey window function used in preprocessing.
+        - `SciPy Tukey window <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.windows.tukey.html>`_
+          Documentation for the Tukey window function used in preprocessing.
     """
     return [
         integ
@@ -106,8 +118,8 @@ def integrals(
             range(n + 1),
             iter_integrals(
                 df,
+                zero=zero,
                 highpass_cutoff=highpass_cutoff,
-                filter_half_order=n // 2 + 1,  # ensures zero DC content in nth integral
                 tukey_percent=tukey_percent,
             ),
         )
diff --git a/endaq/calc/psd.py b/endaq/calc/psd.py
index 0afb798..fb2dc1a 100644
--- a/endaq/calc/psd.py
+++ b/endaq/calc/psd.py
@@ -65,7 +65,7 @@ def _np_histogram_nd(array, bins=10, weights=None, axis=-1, **kwargs):
 
 def welch(
     df: pd.DataFrame,
-    bin_width: float = 1,
+    bin_width: float = 1.0,
     scaling: typing.Literal[None, "density", "spectrum", "parseval"] = None,
     **kwargs,
 ) -> pd.DataFrame:
@@ -110,7 +110,7 @@ def welch(
     )
 
 
-def differentiate(df: pd.DataFrame, n: float = 1) -> pd.DataFrame:
+def differentiate(df: pd.DataFrame, n: float = 1.0) -> pd.DataFrame:
     """
     Perform time-domain differentiation on periodogram data.
 
@@ -193,7 +193,7 @@ def to_jagged(
 
 
 def to_octave(
-    df: pd.DataFrame, fstart: float = 1, octave_bins: float = 12, **kwargs
+    df: pd.DataFrame, fstart: float = 1.0, octave_bins: float = 12.0, **kwargs
 ) -> pd.DataFrame:
     """
     Calculate a periodogram over log-spaced frequency bins.
@@ -226,7 +226,7 @@ def to_octave(
 
 
 def vc_curves(
-    accel_psd: pd.DataFrame, fstart: float = 1, octave_bins: float = 12
+    accel_psd: pd.DataFrame, fstart: float = 1.0, octave_bins: float = 12.0
 ) -> pd.DataFrame:
     """
     Calculate Vibration Criterion (VC) curves from an acceration periodogram.
@@ -272,7 +272,7 @@ def vc_curves(
         df_vel,
         fstart=fstart,  # Hz
         octave_bins=octave_bins,
-        agg=np.sum,
+        agg="sum",
     )
 
     # The PSD must already scale by ∆f -> need only scale by √∆f?
diff --git a/endaq/calc/shock.py b/endaq/calc/shock.py
index c4841e0..5934a15 100644
--- a/endaq/calc/shock.py
+++ b/endaq/calc/shock.py
@@ -3,8 +3,9 @@
 from __future__ import annotations
 
 import typing
-from typing import Tuple
+from typing import Optional
 from collections import namedtuple
+from dataclasses import dataclass
 import functools
 import warnings
 
@@ -232,10 +233,117 @@ def shock_spectrum(
     )
 
 
+@dataclass
+class HalfSineWavePulse:
+    """
+    The output data type for ``enveloping_half_sine``.
+
+    The significant data members are `amplitude` and `duration`, which can
+    simply be unpacked as if from a plain tuple:
+
+    .. testsetup::
+
+        import pandas as pd
+        df_pvss = pd.DataFrame([1, 1], index=[200, 400])
+
+        from endaq.calc.shock import enveloping_half_sine
+
+    .. testcode::
+
+        ampl, T = enveloping_half_sine(df_pvss)
+
+    However, users can also elect to use the other methods of this class to
+    generate other kinds of outputs.
+
+    .. note:: This class is not intended to be instantiated manually.
+    """
+
+    amplitude: pd.Series
+    duration: pd.Series
+
+    def __iter__(self):
+        return iter((self.amplitude, self.duration))
+
+    def to_time_series(
+        self,
+        tstart: Optional[float] = None,
+        tstop: Optional[float] = None,
+        dt: Optional[float] = None,
+        tpulse: Optional[float] = None,
+    ) -> pd.DataFrame:
+        """
+        Generate a time-series of the half-sine pulse.
+
+        :param tstart: the starting time of the resulting waveform; if `None`
+            (default), the range starts at `tpulse`
+        :param tstop: the ending time of the resulting waveform; if `None`
+            (default), the range ends at `tpulse + duration`
+        :param dt: the sampling period of the resulting waveform; defaults to
+            1/20th of the pulse duration
+        :param tpulse: the starting time of the pulse within the resulting
+            waveform; if `None` (default), the pulse starts at either:
+
+            - ``tstart``, if provided
+            - ``tstop - self.duration.max())``, if `tstop` is provided
+            - ``0.0`` otherwise
+        :return: a time-series of the half-sine pulse
+        """
+        if dt is None:
+            dt = self.duration.min() / 20
+        if dt > self.duration.min() / 8:
+            warnings.warn(
+                f"the sampling period {dt} is large relative to the pulse duration"
+                f" {self.duration.min()}; the waveform may not accurately represent"
+                f" the half-sine pulse's shock intensity"
+            )
+
+        default_start = 0.0
+        if tstop is not None:
+            default_start = tstop - self.duration.max()
+
+        if tpulse is None and tstart is None:
+            tpulse = tstart = default_start
+        elif tpulse is None:
+            tpulse = tstart
+        elif tstart is None:
+            tstart = tpulse
+
+        if tstop is None:
+            tstop = tpulse + self.duration.max()
+
+        if not (tstart <= tpulse <= tstop - self.duration.max()):
+            warnings.warn(
+                "half-sine pulse extends beyond the bounds of the time series"
+            )
+
+        t = np.arange(tstart, tstop, dt)
+
+        data = np.zeros((len(t), len(self.amplitude)), dtype=float)
+        t_data, ampl_data, T_data = np.broadcast_arrays(
+            t[..., None], self.amplitude.to_numpy(), self.duration.to_numpy()
+        )
+        t_mask = np.nonzero((t_data >= tpulse) & (t_data < tpulse + T_data))
+        data[t_mask] = ampl_data[t_mask] * np.sin(
+            np.pi * t_data[t_mask] / T_data[t_mask]
+        )
+
+        return pd.DataFrame(
+            data,
+            index=pd.Series(t, name="timestamp"),
+            columns=self.amplitude.index,
+        )
+
+    # def widened_duration(self, new_duration: float):
+    #    pass
+
+    # def pseudo_velocity(self):
+    #    pass
+
+
 def enveloping_half_sine(
     pvss: pd.DataFrame,
     damp: float = 0.0,
-) -> Tuple[pd.Series, pd.Series]:
+) -> HalfSineWavePulse:
     """
     Characterize a half-sine pulse whose PVSS envelopes the input.
 
@@ -274,7 +382,7 @@ def amp_factor(damp):
     max_pvss = pvss.max()
     max_f_pvss = pvss.mul(pvss.index, axis=0).max()
 
-    return namedtuple("HalfSinePulseParameters", "amplitude, period")(
+    return HalfSineWavePulse(
         amplitude=2 * np.pi * max_f_pvss,
-        period=max_pvss / (4 * amp_factor(damp) * max_f_pvss),
+        duration=max_pvss / (4 * amp_factor(damp) * max_f_pvss),
     )
diff --git a/endaq/calc/utils.py b/endaq/calc/utils.py
index 5d9f3fe..87f881d 100644
--- a/endaq/calc/utils.py
+++ b/endaq/calc/utils.py
@@ -12,18 +12,23 @@
 
 
 def sample_spacing(
-    df: pd.DataFrame, convert: typing.Literal[None, "to_seconds"] = "to_seconds"
+    data: Union[np.ndarray, pd.DataFrame],
+    convert: typing.Literal[None, "to_seconds"] = "to_seconds",
 ) -> Union[float, np.timedelta64]:
     """
     Calculate the average spacing between individual samples.
 
     For time indices, this calculates the sampling period `dt`.
 
-    :param df: the input data
+    :param data: the input data; either a pandas DataFrame with the samples
+        spaced along its index, or a 1D-array-like of sample times
     :param convert: if `"to_seconds"` (default), convert any time objects into
         floating-point seconds
     """
-    dt = (df.index[-1] - df.index[0]) / (len(df.index) - 1)
+    if isinstance(data, (pd.DataFrame, pd.Series)):
+        data = data.index
+
+    dt = (data[-1] - data[0]) / (len(data) - 1)
     if convert == "to_seconds" and isinstance(dt, (np.timedelta64, pd.Timedelta)):
         dt = dt / np.timedelta64(1, "s")
 
@@ -31,7 +36,7 @@ def sample_spacing(
 
 
 def logfreqs(
-    df: pd.DataFrame, init_freq: Optional[float] = None, bins_per_octave: float = 12
+    df: pd.DataFrame, init_freq: Optional[float] = None, bins_per_octave: float = 12.0
 ) -> np.ndarray:
     """
     Calculate a sequence of log-spaced frequencies for a given dataframe.
@@ -131,7 +136,7 @@ def resample(df: pd.DataFrame, sample_rate: Optional[float] = None) -> pd.DataFr
     resampled_data, resampled_time = scipy.signal.resample(
         df,
         num_samples_after_resampling,
-        t=df.index,
+        t=df.index.values.astype('datetime64[s]'),
     )
     resampled_df = pd.DataFrame(
         resampled_data,
diff --git a/endaq/cloud/__init__.py b/endaq/cloud/__init__.py
index 468c910..c36e9ac 100644
--- a/endaq/cloud/__init__.py
+++ b/endaq/cloud/__init__.py
@@ -1,2 +1,3 @@
 from .core import *
 from .scripting import *
+from . import example_scripts
diff --git a/endaq/cloud/core.py b/endaq/cloud/core.py
index d11e8c1..ac9d743 100644
--- a/endaq/cloud/core.py
+++ b/endaq/cloud/core.py
@@ -3,7 +3,7 @@
 """
 from __future__ import annotations
 
-from typing import Optional, Union
+from typing import Optional, Union, Callable
 
 from idelib.dataset import Dataset
 import numpy as np
@@ -13,6 +13,9 @@
 import re
 import urllib.request
 import shutil
+import os
+import pathlib
+import warnings
 
 
 __all__ = [
@@ -124,6 +127,64 @@ def get_file(self,
 
         return Dataset(f)
 
+    def download_all_ide_files(self,
+                               output_directory: Union[str, pathlib.Path] = "",
+                               should_download_file_fn: Optional[Callable] = None,
+                               force_reload_file_table: bool = False,
+                               file_limit: int = 100) -> np.ndarray:
+        """
+        Downloads all IDE files from the enDAQ Cloud (up to a specified file limit).
+
+        :param output_directory: The directory to download the ide files to
+        :param should_download_file_fn: A function which accepts a row of the IDE file table and returns
+         a boolean value which indicates if the IDE file should be downloaded or not.  If this function is
+         not given, the default function will always return True.
+        :param force_reload_file_table: If the file table to use as reference for what files exist in the cloud
+         should be recomputed even if it is already stored (as `self.file_table`)
+        :param file_limit: The maximum number of files to download.  If the `force_recompute_file_table` parameter
+         is True then this will also be used to limit the number of files put in the file table it creates.
+        :return: An array of the filenames which were just downloaded
+
+        TO-DO:
+         - Would be nice to have a parameter to get only ones with a certain tag
+         - Maybe Have a blacklist and/or whitelist parameter
+        """
+        if not isinstance(output_directory, (str, pathlib.Path)):
+            raise TypeError('the "output_directory" parameter must be given a string,'
+                            f'but was given a value of type{type(output_directory)}')
+
+        if not isinstance(force_reload_file_table, bool):
+            raise TypeError('the "force_recompute_file_table" parameter must be given a value of type bool,'
+                            f'but was given a value of type{type(force_reload_file_table)}')
+
+        if not isinstance(file_limit, (int, np.integer)):
+            raise TypeError('the "file_limit" parameter must be given a value of type int or np.integer,'
+                            f'but was given a value of type{type(file_limit)}')
+
+        if should_download_file_fn is None:
+            should_download_file_fn = lambda x: True
+
+        if self.file_table is None or force_reload_file_table:
+            self.get_file_table(attributes=[], limit=file_limit)
+
+        failures = []
+
+        # The range in this iterator is just a way to force stop the loop
+        for (file_name, file_data), _ in zip(list(self.file_table.iterrows()), range(file_limit)):
+            if should_download_file_fn(file_data):
+                try:
+                    self.get_file(file_data['id'], os.path.join(output_directory, file_name))
+                except:
+                    failures.append(file_name)
+
+        downloaded_filename_ary = self.file_table.index.values
+
+        if len(failures):
+            warnings.warn(f"{len(failures)} FILES FAILED TO BE DOWNLOADED!  Those files are:")
+            warnings.warn(', '.join(failures))
+
+        return downloaded_filename_ary
+
     def _get_files_json_response(self, limit: int = 100, attributes: Union[list, str] = "all") -> list:
         """
         A function to get attribute data about the most recent files uploaded to the cloud.
diff --git a/endaq/cloud/example_scripts/__init__.py b/endaq/cloud/example_scripts/__init__.py
new file mode 100644
index 0000000..50c0f5a
--- /dev/null
+++ b/endaq/cloud/example_scripts/__init__.py
@@ -0,0 +1,2 @@
+from .single_file_dashboard import *
+from .correlation_explorer_dashboard import *
diff --git a/endaq/cloud/example_scripts/correlation_explorer_dashboard.py b/endaq/cloud/example_scripts/correlation_explorer_dashboard.py
new file mode 100644
index 0000000..ad1721a
--- /dev/null
+++ b/endaq/cloud/example_scripts/correlation_explorer_dashboard.py
@@ -0,0 +1,63 @@
+def cloud_correlation_script(files: list, file_download_url: str):
+    """
+    Creates a cloud dashboard (for use in cloud.endaq.com custom report generation) for comparing various recording
+    files and exploring the correlations between them.  It creates a PCA plot with dropdown menus to select the
+    PCA components, a plot which compares different file attribute values against eachother, a t-SNE plot, and
+    all the attribute values.
+
+    :param files: A `list` of JSON blobs of recording file data as it's given in a custom report in cloud.endaq.com
+    :param file_download_url: The download URL for the IDE file most recently uploaded to cloud.endaq.com
+    :return: The json based string which is to be given as the variable 'output' in enDAQ cloud
+     custom dashboards (to produce the dashboard)
+    """
+    import numpy as np
+    from endaq.plot.utilities import set_theme
+    from endaq.cloud.core import json_table_to_df
+    from endaq.plot import get_tsne_plot, get_pure_numpy_2d_pca, general_get_correlation_figure,\
+        multi_file_plot_attributes
+    from endaq.cloud import create_cloud_dashboard_output
+
+    # Set the aesthetic theme of the figures, and store the list of colors used as a colorway for future use
+    colorway = set_theme()['layout']['colorway']
+
+    # Remove all file data which doesn't contain any attribute data
+    files = [f for f in files if f['attributes']]
+
+    # Ensure that there are at least 2 files
+    if len(files) <= 2:
+        raise ValueError(f"Must have at least 2 files given to analyze, was given {len(files)} instead")
+
+    # Get the dataframe of the file attribute data
+    df = json_table_to_df(files)
+
+    # Create a dictionary mapping serial number of the enDAQ recording device with
+    unique_serial_nums = np.unique(df['serial_number_id'])
+    serials_to_colors = {serial_num: c for c, serial_num in
+                        zip(colorway * (1 + len(unique_serial_nums) // len(colorway)), unique_serial_nums)}
+
+    # The dictionary to be used to create the desired output of this function from (see the final line of this script
+    # for how it's used to accomplish this)
+    name_to_fig = dict()
+
+    # Create PCA plot with 2 drop-down menus
+    name_to_fig["PCA Plot"] = get_pure_numpy_2d_pca(df, color_discrete_map=serials_to_colors)
+
+    # Create a plot that plots one attribute value against another attribute value, the two
+    # attributes are selected via two dropdown menus
+    name_to_fig["Attribute Correlation Explorer"] = general_get_correlation_figure(
+        df,
+        color_discrete_map=serials_to_colors,
+        characteristics_to_show_on_hover=['id', 'recording_length', 'accelerationRMSFull', 'serial_number_id'],
+    )
+
+    # Create the t-SNE visualization
+    name_to_fig["t-SNE"] = get_tsne_plot(df, color_discrete_map=serials_to_colors)
+
+    # Generate Row of Subplots
+    name_to_fig["Measurements Pane"] = multi_file_plot_attributes(
+        df, recording_colors=df['serial_number_id'].map(serials_to_colors.get))
+
+    # Create JSON Output
+    output = create_cloud_dashboard_output(name_to_fig)
+
+    return output
diff --git a/endaq/cloud/example_scripts/single_file_dashboard.py b/endaq/cloud/example_scripts/single_file_dashboard.py
new file mode 100644
index 0000000..8b7abd6
--- /dev/null
+++ b/endaq/cloud/example_scripts/single_file_dashboard.py
@@ -0,0 +1,62 @@
+def single_file_analysis_script(files: list,
+                                file_download_url: str,
+                                channel: str = '40g DC Acceleration',
+                                subchannel: str = 'Z (40g)') -> str:
+    """
+    Creates a cloud dashboard (for use in cloud.endaq.com custom report generation) for plotting and exploring the
+    data in the most recently uploaded file to the enDAQ Cloud.  It creates a PSD plot with octave spaced frequency
+    bins, a plot of the channel data around the peak value, a spectrogram of subchannel data that's frequency bins are
+    octave spaced, and a rolling envelope of every sensors data.
+
+    :param files: A `list` of JSON blobs of recording file data as it's given in a custom report in cloud.endaq.com
+    :param file_download_url: The download URL for the IDE file most recently uploaded to cloud.endaq.com
+    :param channel: The name of the channel to focus the analysis on
+    :param subchannel: The name of the subchannel to focus some of the analysis on
+    :return: The json based string which is to be given as the variable 'output' in enDAQ cloud
+     custom dashboards (to produce the dashboard)
+    """
+    from endaq.ide import get_doc, to_pandas
+    from endaq.plot import octave_spectrogram, octave_psd_bar_plot, around_peak
+    from endaq.plot.dashboards import rolling_enveloped_dashboard
+    from endaq.plot.utilities import set_theme
+    from endaq.cloud import create_cloud_dashboard_output
+
+    # Set the aesthetic theme of the figures (defaults to enDAQ dark theme)
+    set_theme()
+
+    # Gets the IDE data as an idelib.dataset.Dataset object
+    doc = get_doc(file_download_url)
+
+    # A dictionary mapping channel names to pandas dataframes of that channels data
+    channel_dict = {doc.channels[ch].name: to_pandas(doc.channels[ch]) for ch in doc.channels}
+
+    # Check that the desired channel exists
+    if channel not in channel_dict:
+        raise ValueError(f"'{channel}' was not in the list of available channels.")
+
+    # Check that the desired subchannel exists
+    if subchannel not in channel_dict[channel]:
+        raise ValueError(f"'{subchannel}' was not in the list of available subchannels of {channel}.")
+
+    # Get the acceleration dataframe
+    acceleration_df = channel_dict[channel]
+
+    name_to_fig = dict()
+
+    # Create a bar plot of PSD data with octave spaced frequency bins
+    name_to_fig["Acceleration PSD"] = octave_psd_bar_plot(acceleration_df[[subchannel]], yaxis_title=subchannel)
+
+    # Create a plot of the data surrounding the time of peak magnitude in the channel
+    name_to_fig["Acceleration Around Peak"] = around_peak(acceleration_df, 1000)
+
+    # Create a spectrogram with octave spaced frequency bins
+    name_to_fig["Z-Axis Acceleration Spectrogram"] = octave_spectrogram(acceleration_df[[subchannel]], window=.15)[-1]
+
+    # Generate Row of Subplots
+    name_to_fig["Rolling Envelopes"] = rolling_enveloped_dashboard(
+        channel_dict, plot_as_bars=True, num_rows=1, num_cols=None)
+
+    # Create JSON Output
+    output = create_cloud_dashboard_output(name_to_fig)
+
+    return output
diff --git a/endaq/cloud/scripting.py b/endaq/cloud/scripting.py
index 353ad56..571e675 100644
--- a/endaq/cloud/scripting.py
+++ b/endaq/cloud/scripting.py
@@ -3,7 +3,7 @@
 import plotly.io as pio
 import json
 
-from .core import ENV_PRODUCTION, ENV_STAGING, ENV_DEVELOP
+from .core import EndaqCloud, ENV_PRODUCTION, ENV_STAGING, ENV_DEVELOP
 
 
 __all__ = [
@@ -34,8 +34,8 @@ def create_cloud_dashboard_output(name_to_fig: dict) -> str:
     return "[" + ", ".join([v.to_json()[:-1] + ', "title": "' + k + '"}' for k, v in name_to_fig.items()]) + "]"
 
 
-def produce_dashboard_plots(dashboard_script_fn: FunctionType, api_key: str, environment: str = 'production',
-                            display_plots: bool = True) -> list:
+def produce_dashboard_plots(dashboard_script_fn: FunctionType, api_key: str, max_num_files: int = 100,
+                            environment: str = 'production', display_plots: bool = True) -> list:
     """
     A function used to simulate a run of a desired enDAQ Cloud custom report script without needing to use
     cloud.endaq.com
@@ -44,29 +44,34 @@ def produce_dashboard_plots(dashboard_script_fn: FunctionType, api_key: str, env
      the exact code that would be put into a enDAQ Cloud custom report script, followed by one final line:
      `return output`
     :param api_key: The enDAQ Cloud API key
+    :param max_num_files: The maximum number of files to get data about.  Specifically, this is used to
+     specify how many of the most recently uploaded IDE files in the cloud will have their info passed to
+     your custom report script (through a list of json blobs, as parameter 'files')
     :param environment: The version of the enDAQ Cloud to communicate with, the options are 'production', 'staging',
      or 'develop'.  This should only be used internally at Mide
     :param display_plots: If the plots being produced should be displayed
     :return: A list of the 4 plotly figures produced
     """
     if environment == 'production':
-        api_access_url = ENV_PRODUCTION + '/api/v1/'
+        api_access_url = ENV_PRODUCTION
     elif environment == 'staging':
-        api_access_url = ENV_STAGING + '/api/v1/'
+        api_access_url = ENV_STAGING
     elif environment == 'develop':
-        api_access_url = ENV_DEVELOP + '/api/v1/'
+        api_access_url = ENV_DEVELOP
     else:
         raise ValueError("Only 'production', 'staging', and 'develop' may be given for the 'environment' parameter, "
                          f" but {environment} was given instead.")
 
     parameters = {"x-api-key": api_key}
 
-    files = requests.get(api_access_url + "files?limit=100&attributes=all", headers=parameters).json()['data']
+    cloud_obj = EndaqCloud(api_key, env=api_access_url)
+
+    files = cloud_obj._get_files_json_response(limit=max_num_files)
 
     most_recent_file_id = files[-1]['id']
 
     file_download_url = requests.get(
-        api_access_url + "files/download/" + most_recent_file_id,
+        api_access_url + '/api/v1/files/download/' + most_recent_file_id,
         headers=parameters
     ).json()['url']
 
diff --git a/endaq/ide/files.py b/endaq/ide/files.py
index 5288bce..47d4f3b 100644
--- a/endaq/ide/files.py
+++ b/endaq/ide/files.py
@@ -27,13 +27,14 @@
 # ============================================================================
 
 
-def _get_url(url, localfile=None, params=None, cookies=None):
+def _get_url(url, localfile=None, headers=None, params=None, cookies=None):
     """
     Retrieve an IDE from a (HTTP/HTTPS) URL, including Google Drive shared
     links.
 
     :param url: The file's URL.
     :param localfile: The local filename (if saving the file).
+    :param headers: Additional (optional) request headers.
     :param params: Additional (optional) request parameters.
     :param cookies: Optional browser cookies for the session.
     :return: An open file stream containing the IDE data and the number of
@@ -44,9 +45,11 @@ def _get_url(url, localfile=None, params=None, cookies=None):
 
     netloc = parsed_url.netloc.lower()
     if netloc.endswith('.google.com') or netloc == "google.com":
-        response, filename = gdrive_download(url, localfile, params=params, cookies=cookies)
+        response, filename = gdrive_download(url, localfile, params=params,
+                                             cookies=cookies)
     else:
-        response = session.get(parsed_url.geturl(), params=params, cookies=cookies)
+        response = session.get(parsed_url.geturl(), headers=headers,
+                               params=params, cookies=cookies)
         filename = None
 
     if not response.ok:
@@ -83,7 +86,7 @@ def _get_url(url, localfile=None, params=None, cookies=None):
 # ============================================================================
 
 def get_doc(name=None, filename=None, url=None, parsed=True, start=0, end=None,
-            localfile=None, params=None, cookies=None, **kwargs):
+            localfile=None, params=None, headers=None, cookies=None, **kwargs):
     """
     Retrieve an IDE file from either a file or URL.
 
@@ -135,6 +138,8 @@ def get_doc(name=None, filename=None, url=None, parsed=True, start=0, end=None,
         applicable when opening a URL.
     :param params: Additional URL request parameters. Only applicable when
         opening a URL.
+    :param headers: Additional URL request headers. Only applicable when
+        opening a URL.
     :param cookies: Additional browser cookies for use in the URL request.
         Only applicable when opening a URL.
     :return: The fetched IDE data.
@@ -150,28 +155,35 @@ def get_doc(name=None, filename=None, url=None, parsed=True, start=0, end=None,
     parsed_url = None
 
     if name:
+        # Infer filename vs. URL
+        name = name.strip()
         if os.path.isfile(name):
             filename = name
         else:
             parsed_url = urlparse(name.replace('\\', '/'))
-            if not parsed_url.scheme or parsed_url.scheme == "file":
+            if parsed_url.scheme == 'file':
                 filename = parsed_url.path
-            else:
+            elif parsed_url.netloc:
                 url = name
+            else:
+                filename = name
 
-    if filename:
-        filename = os.path.abspath(os.path.expanduser(filename))
-        stream = open(filename, 'rb')
-
-    elif url:
+    if url:
         kwargs.setdefault('name', url)
         parsed_url = parsed_url or urlparse(url)
-        if parsed_url.scheme.startswith('http'):
-            stream, _total = _get_url(url, localfile=localfile, params=params, cookies=cookies)
+        if parsed_url.scheme == 'file':
+            filename = parsed_url.path
+        elif parsed_url.scheme.startswith('http'):
+            stream, _total = _get_url(url, localfile=localfile, headers=headers,
+                                      params=params, cookies=cookies)
         else:
             # future: more fetching schemes before this `else` (ftp, etc.)?
             raise ValueError(f"Unsupported transfer scheme: {parsed_url.scheme}")
 
+    if filename:
+        filename = os.path.abspath(os.path.expanduser(filename))
+        stream = open(filename, 'rb')
+
     if stream:
         if not validate(stream):
             stream.close()
@@ -210,10 +222,11 @@ def get_doc(name=None, filename=None, url=None, parsed=True, start=0, end=None,
 
 def extract_time(doc, out, start=0, end=None, channels=None, **kwargs):
     """
-    Efficiently extract data within a certain interval from an IDE file. Note
-    that due to the way data is stored in an IDE, the exported interval will
-    be slightly wider than the specified start and end times; this ensures
-    the data is copied verbatim and without loss.
+    Efficiently extract data within a certain interval from an IDE file,
+    writing it to another file. Note that due to the way data is stored in an
+    IDE, the exported interval will be slightly wider than the specified
+    start and end times; this ensures the data is copied verbatim and without
+    loss.
 
     The `start` and `end` times, if used, may be specified in several
     ways:
diff --git a/endaq/ide/info.py b/endaq/ide/info.py
index 10b22fc..6328e6a 100644
--- a/endaq/ide/info.py
+++ b/endaq/ide/info.py
@@ -301,12 +301,14 @@ def to_pandas(
 
         :param channel: a `Channel` object, as produced from `Dataset.channels`
             or `endaq.ide.get_channels`
-        :kwarg time_mode: how to temporally index samples; each mode uses either
+        :param time_mode: how to temporally index samples; each mode uses either
             relative times (with respect to the start of the recording) or
             absolute times (i.e., date-times):
-            - "seconds" - a `pandas.Float64Index` of relative timestamps, in seconds
-            - "timedelta" - a `pandas.TimeDeltaIndex` of relative timestamps
-            - "datetime" - a `pandas.DateTimeIndex` of absolute timestamps
+
+            * `"seconds"` - a `pandas.Float64Index` of relative timestamps, in seconds
+            * `"timedelta"` - a `pandas.TimeDeltaIndex` of relative timestamps
+            * `"datetime"` - a `pandas.DateTimeIndex` of absolute timestamps
+
         :return: a `pandas.DataFrame` containing the channel's data
     """
     data = channel.getSession().arraySlice()
@@ -319,7 +321,7 @@ def to_pandas(
         t = t + np.datetime64(channel.dataset.lastUtcTime, "s")
     elif time_mode != "timedelta":
         raise ValueError(f'invalid time mode "{time_mode}"')
-    
+
     if hasattr(channel, "subchannels"):
         columns = [sch.name for sch in channel.subchannels]
     else:
diff --git a/endaq/ide/measurement.py b/endaq/ide/measurement.py
index 09369a2..b2b0333 100644
--- a/endaq/ide/measurement.py
+++ b/endaq/ide/measurement.py
@@ -1,6 +1,28 @@
 """
-Functions for filtering data by measurement types, and singleton objects
-representing different measurement types.
+The module :py:mod:`endaq.ide.measurement` provides an abstract representation of measurement types for
+easily retrieving specific data from ``.ide`` files. Several functions in :py:mod:`endaq.ide` accept
+combinations of ``MeasurementType`` constants for filtering datasets by sensor type.
+
+Measurement types are represented by a set of singleton instances of :py:class:`MeasurementType`.
+
+.. code:: python3
+
+    import endaq.ide
+    from endaq.ide.measurement import *
+
+    doc = endaq.ide.get_doc("https://info.endaq.com/hubfs/data/surgical-instrument.ide")
+    endaq.ide.get_channels(doc, ACCELERATION)
+    endaq.ide.get_channels(doc, TEMPERATURE+PRESSURE)
+
+Strings may also be used, either in combination with or instead of, the :py:class:`MeasurementType` instances.
+The strings can be abbreviated, but no shorter than three characters.
+
+.. code:: python3
+
+    endaq.ide.get_channels(doc, "acceleration")
+    endaq.ide.get_channels(doc, "accel")
+    endaq.ide.get_channels(doc, ACCELERATION+"temp")
+
 """
 
 __all__ = ['ANY', 'ACCELERATION', 'ALTITUDE', 'ANG_RATE', 'AUDIO', 'DIRECTION',
@@ -173,40 +195,62 @@ def match(self, channel):
 ANY = MeasurementType("Any/all", "*", "any", "all",
     doc="Marker object for matching any/all measurement types",
     labels=("*",))
+""" Marker object for matching any/all measurement types """
 
 ACCELERATION = MeasurementType("Acceleration", "acc", "g",
     doc="Marker object for filtering channels with acceleration data",
     labels=())
+""" Marker object for filtering channels with acceleration data """
+
 ORIENTATION = MeasurementType("Orientation", "imu", "qua", "gyr",
     doc="Marker object for filtering channels with rotation/orientation data",
     labels=("quaternion", "euler", "orientation"))
+"Marker object for filtering channels with rotation/orientation data"
+
 ROTATION = MeasurementType("Rotation", "rot", "ang",
     doc="Marker object for filtering channels with angular change rate data",
     labels=("rotation", "gyro"))
+"Marker object for filtering channels with angular change rate data"
+
 AUDIO = MeasurementType("Audio", "mic",
     doc="Marker object for filtering channels with sound level data",
     labels=("mic",))
+"Marker object for filtering channels with sound level data"
+
 LIGHT = MeasurementType("Light", "lux",
     doc="Marker object for filtering channels with light intensity data",
     labels=("lux", "uv"))
+"Marker object for filtering channels with light intensity data"
+
 PRESSURE = MeasurementType("Pressure",
     doc="Marker object for filtering channels with air pressure data",
     labels=())  # pressures
+"Marker object for filtering channels with air pressure data"
+
 TEMPERATURE = MeasurementType("Temperature",
     doc="Marker object for filtering channels with temperature data",
     labels=())  # temperature
+"Marker object for filtering channels with temperature data"
+
 HUMIDITY = MeasurementType("Relative Humidity", "hum",
     doc="Marker object for filtering channels with (relative) humidity data",
     labels=())  # Humidity
+"Marker object for filtering channels with (relative) humidity data"
+
 LOCATION = MeasurementType("Location", "pos", "gps",
     doc="Marker object for filtering channels with location data",
     labels=("pos",))  # GPS
+"Marker object for filtering channels with location data"
+
 SPEED = MeasurementType("Speed",
     doc="Marker object for filtering channels with rate-of-speed data",
     labels=("velocity",))  # GPS Ground Speed
+"Marker object for filtering channels with rate-of-speed data"
+
 TIME = MeasurementType("Time", "epo",
     doc="Marker object for filtering channels with time data",
     labels=("epoch",))  # GPS Epoch Time
+"Marker object for filtering channels with time data"
 
 # For potential future use
 GENERIC = MeasurementType("Generic/Unspecified", "adc", "raw",
diff --git a/endaq/plot/dashboards.py b/endaq/plot/dashboards.py
index bdf7261..085bdd2 100644
--- a/endaq/plot/dashboards.py
+++ b/endaq/plot/dashboards.py
@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from collections import Container
+from collections.abc import Container
 import numpy as np
 import pandas as pd
 import plotly.graph_objects as go
@@ -13,7 +13,7 @@ def rolling_enveloped_dashboard(
     channel_df_dict: dict, desired_num_points: int = 250, num_rows: Optional[int] = None,
     num_cols: Optional[int] = 3, width_for_subplot_row: int = 400, height_for_subplot_row: int = 400,
     subplot_colors: Optional[Container] = None, min_points_to_plot: int = 1, plot_as_bars: bool = False,
-    plot_full_single_channel: bool = False, opacity: float = 1, y_axis_bar_plot_padding: float = 0.06
+    plot_full_single_channel: bool = False, opacity: float = 1.0, y_axis_bar_plot_padding: float = 0.06
 ) -> go.Figure:
     """
     A function to create a Plotly Figure with sub-plots for each of the available data sub-channels, designed to reduce
diff --git a/endaq/plot/plots.py b/endaq/plot/plots.py
index 2de047f..e04b951 100644
--- a/endaq/plot/plots.py
+++ b/endaq/plot/plots.py
@@ -7,7 +7,7 @@
 from plotly.subplots import make_subplots
 from scipy import signal
 from typing import Optional
-from collections import Container
+from collections.abc import Container
 
 from endaq.calc import sample_spacing
 from endaq.calc.psd import to_octave, welch
@@ -375,7 +375,7 @@ def octave_psd_bar_plot(df: pd.DataFrame, bins_per_octave: int = 3, f_start: flo
 
 
 def rolling_min_max_envelope(df: pd.DataFrame, desired_num_points: int = 250, plot_as_bars: bool = False,
-                             plot_title: str = "", opacity: float = 1,
+                             plot_title: str = "", opacity: float = 1.0,
                              colors_to_use: Optional[Container] = None) -> go.Figure:
     """
     A function to create a Plotly Figure to plot the data for each of the available data sub-channels, designed to
diff --git a/endaq/plot/t_sne.py b/endaq/plot/t_sne.py
index 7b2c361..33dfdef 100644
--- a/endaq/plot/t_sne.py
+++ b/endaq/plot/t_sne.py
@@ -82,9 +82,13 @@ def find_optimal_sigmas(distances: np.ndarray, target_perplexity: int, max_itera
     return np.array(sigmas)
 
 
-def get_tsne_plot(df: pd.DataFrame, perplexity: int = 20, random_seed: int = 0, num_iterations: int = 1000,
+def get_tsne_plot(df: pd.DataFrame,
+                  perplexity: int = 20,
+                  random_seed: int = 0,
+                  num_iterations: int = 1000,
                   learning_rate: int = 500,
-                  serials_to_indices: dict = None,
+                  color_col: str = 'serial_number_id',
+                  color_discrete_map: Optional[dict] = None,
                   momentum: int = 0) -> go.Figure:
     """
     Estimates a SNE model, and plots the visualization of the given data.
@@ -100,6 +104,10 @@ def get_tsne_plot(df: pd.DataFrame, perplexity: int = 20, random_seed: int = 0,
     :param random_seed: Integer value to set the seed value for the random
     :param num_iterations: The number of iterations to train for
     :param learning_rate: The rate at which to update teh values in the model
+    :param color_col: The column name in the given dataframe (as merged_df) that is used to color
+     data points with.   This is used in combination with the color_discrete_map parameter
+    :param color_discrete_map: A dictionary which maps the values given to color data points based on (see the
+     color_col parameter description) to the colors that these data points should be
     :param momentum:  The momentum to be used when applying updates to the model
     :return: Plotly figure of the, low-dimensional representation of the given data
     """
@@ -150,9 +158,8 @@ def get_tsne_plot(df: pd.DataFrame, perplexity: int = 20, random_seed: int = 0,
     fig = px.scatter(
         x=Y[:, 0],
         y=Y[:, 1],
-        # color=colors,
-        color=df['serial_number_id'],
-        color_discrete_map=serials_to_indices,
+        color=df[color_col],
+        color_discrete_map=color_discrete_map,
         hover_name=df.index,
     )
 
diff --git a/setup.py b/setup.py
index ba1f393..be4031d 100644
--- a/setup.py
+++ b/setup.py
@@ -34,7 +34,7 @@
 
 setuptools.setup(
         name='endaq',
-        version='1.2.0',
+        version='1.3.0',
         author='Mide Technology',
         author_email='help@mide.com',
         description='A comprehensive, user-centric Python API for working with enDAQ data and devices',
@@ -45,8 +45,6 @@
         classifiers=['Development Status :: 4 - Beta',
                      'License :: OSI Approved :: MIT License',
                      'Natural Language :: English',
-                     'Programming Language :: Python :: 3.5',
-                     'Programming Language :: Python :: 3.6',
                      'Programming Language :: Python :: 3.7',
                      'Programming Language :: Python :: 3.8',
                      'Programming Language :: Python :: 3.9',
@@ -59,6 +57,11 @@
         #     'idelib': ['schemata/*'],
         # },
         # test_suite='./testing',
+        project_urls={
+            "Bug Tracker": "https://github.com/MideTechnology/endaq-python/issues",
+            "Documentation": "https://docs.endaq.com/en/latest/",
+            "Source Code": "https://github.com/MideTechnology/endaq-python/tree/main",
+            },
         install_requires=INSTALL_REQUIRES,
         extras_require={
             'test': INSTALL_REQUIRES + TEST_REQUIRES,


Test patch:
diff --git a/.github/workflows/docs-tests.yml b/.github/workflows/docs-tests.yml
new file mode 100644
index 0000000..0800f4d
--- /dev/null
+++ b/.github/workflows/docs-tests.yml
@@ -0,0 +1,58 @@
+name: Docs test on push and PR
+
+
+on: [push, pull_request, workflow_dispatch]
+
+
+jobs:
+  unit-tests:
+    name: Run Docs Tests
+
+    strategy:
+      fail-fast: false
+
+    env:
+      OS: ubuntu-latest
+      PYTHON-VERSION: "3.9"
+
+    runs-on: ubuntu-latest
+
+    steps:
+
+      - name: Setup Python
+        uses: actions/setup-python@v2
+        with:
+          python-version: "3.9"
+
+      - name: Upgrade pip
+        run: python -m pip install --upgrade pip
+
+      - name: Checkout code
+        uses: actions/checkout@v2
+
+      - name: Get pip cache location
+        id: pip-cache
+        run: echo "::set-output name=dir::$(pip cache dir)"
+
+      - name: Get date
+        id: date
+        run: >
+          python -c
+          "import datetime;
+          now = datetime.datetime.now();
+          print(f'::set-output name=text::{now.year}/{now.month}-part{1 + now.day // 8}')"
+
+      - name: Load pip cache
+        uses: actions/cache@v2
+        with:
+          path: ${{ steps.pip-cache.outputs.dir }}
+          key: doctests-${{ runner.os }}-Python3.9-${{ steps.date.outputs.text }}-pip-${{ hashFiles('**/setup.py', './.github/workflows/unit-tests.yml') }}
+
+      - name: Install package & (test) dependencies
+        run: python -m pip install --upgrade --upgrade-strategy=eager .[docs]
+
+      - name: Build docs
+        run: sphinx-build -W -b html docs docs/_build
+
+      - name: Run docs tests
+        run: sphinx-build -W -b doctest docs docs/_doctest
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 9ebfcfb..23ec013 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -39,7 +39,11 @@ jobs:
 
       - name: Get date
         id: date
-        run: python -c "import datetime; now = datetime.datetime.now(); print(f'::set-output name=text::{now.year}/{now.month}-part{1 + now.day // 8}')"
+        run: >
+          python -c
+          "import datetime;
+          now = datetime.datetime.now();
+          print(f'::set-output name=text::{now.year}/{now.month}-part{1 + now.day // 8}')"
 
       - name: Load pip Cache
         uses: actions/cache@v2
@@ -51,7 +55,14 @@ jobs:
         run: python -m pip install --upgrade --upgrade-strategy=eager .[test]
 
       - name: Run Tests
-        run: python -m pytest tests --cov=endaq --hypothesis-profile=ci --numprocesses auto
+        run: >
+          python -m pytest tests
+          --cov=endaq
+          --hypothesis-profile=ci
+          --numprocesses auto
+          -W error::RuntimeWarning
+          -W error::UserWarning
+          -W error::DeprecationWarning
 
       - name: Generate Codecov Report
         uses: codecov/codecov-action@v2
diff --git a/tests/batch/test_analyzer.py b/tests/batch/test_analyzer.py
index 82b9dcc..b0cfcf5 100644
--- a/tests/batch/test_analyzer.py
+++ b/tests/batch/test_analyzer.py
@@ -5,6 +5,9 @@
 import numpy as np
 import pandas as pd
 import pytest
+import hypothesis as hyp
+import hypothesis.strategies as hyp_st
+import hypothesis.extra.numpy as hyp_np
 
 import endaq.batch.analyzer
 from endaq.calc.stats import rms, L2_norm
@@ -22,13 +25,13 @@ def ide_SSX70065():
 @pytest.fixture()
 def analyzer_raw():
     analyzer_mock = mock.create_autospec(
-        endaq.batch.analyzer.Analyzer, spec_set=False, instance=True
+        endaq.batch.analyzer.CalcCache, spec_set=False, instance=True
     )
 
-    analyzer_mock.MPS2_TO_G = endaq.batch.analyzer.Analyzer.MPS2_TO_G
-    analyzer_mock.MPS_TO_MMPS = endaq.batch.analyzer.Analyzer.MPS_TO_MMPS
-    analyzer_mock.M_TO_MM = endaq.batch.analyzer.Analyzer.M_TO_MM
-    analyzer_mock.PV_NATURAL_FREQS = endaq.batch.analyzer.Analyzer.PV_NATURAL_FREQS
+    analyzer_mock.MPS2_TO_G = endaq.batch.analyzer.MPS2_TO_G
+    analyzer_mock.MPS_TO_MMPS = endaq.batch.analyzer.MPS_TO_MMPS
+    analyzer_mock.M_TO_MM = endaq.batch.analyzer.M_TO_MM
+    analyzer_mock.PV_NATURAL_FREQS = endaq.batch.analyzer.CalcCache.PV_NATURAL_FREQS
 
     return analyzer_mock
 
@@ -96,39 +99,139 @@ def analyzer_bulk(analyzer_raw):
 
 
 class TestAnalyzer:
+    def test_from_ide_vs_from_literal(self, ide_SSX70065):
+        dataset = ide_SSX70065
+        calc_params = endaq.batch.analyzer.CalcParams(
+            accel_start_time=None,
+            accel_end_time=None,
+            accel_start_margin=None,
+            accel_end_margin=None,
+            accel_highpass_cutoff=1,
+            accel_integral_tukey_percent=0,
+            accel_integral_zero="mean",
+            psd_freq_bin_width=1,
+            psd_window="hann",
+            pvss_init_freq=1,
+            pvss_bins_per_octave=12,
+            vc_init_freq=1,
+            vc_bins_per_octave=3,
+        )
+
+        dataset_cache = endaq.batch.analyzer.CalcCache.from_ide(dataset, calc_params)
+
+        raw_cache = endaq.batch.analyzer.CalcCache.from_raw_data(
+            [
+                (
+                    endaq.ide.to_pandas(dataset.channels[32], time_mode="timedelta"),
+                    ("Acceleration", "g"),
+                ),
+                (
+                    endaq.ide.to_pandas(
+                        dataset.channels[36].subchannels[0], time_mode="timedelta"
+                    ),
+                    ("Pressure", "Pa"),
+                ),
+                (
+                    endaq.ide.to_pandas(
+                        dataset.channels[36].subchannels[1], time_mode="timedelta"
+                    ),
+                    ("Temperature", "°C"),
+                ),
+            ],
+            calc_params,
+        )
+
+        assert set(dataset_cache._channels) == set(raw_cache._channels)
+
+        for (ds_struct, raw_struct) in (
+            (dataset_cache._channels[measure_key], raw_cache._channels[measure_key])
+            for measure_key in dataset_cache._channels
+        ):
+            assert ds_struct.units == raw_struct.units
+            pd.testing.assert_frame_equal(
+                ds_struct.to_pandas(time_mode="timedelta"),
+                raw_struct.to_pandas(time_mode="timedelta"),
+            )
+
+    @hyp.given(
+        df=hyp_np.arrays(
+            elements=hyp_st.floats(-1e7, 1e7),
+            shape=(20, 2),
+            dtype=np.float64,
+        ).map(
+            lambda array: pd.DataFrame(
+                array, index=np.timedelta64(200, "ms") * np.arange(20)
+            )
+        ),
+    )
+    def test_accelerationData(self, df):
+        calc_params = endaq.batch.analyzer.CalcParams(
+            accel_start_time=None,
+            accel_end_time=None,
+            accel_start_margin=None,
+            accel_end_margin=None,
+            accel_highpass_cutoff=1,
+            accel_integral_tukey_percent=0,
+            accel_integral_zero="mean",
+            psd_freq_bin_width=1,
+            psd_window="hann",
+            pvss_init_freq=1,
+            pvss_bins_per_octave=12,
+            vc_init_freq=1,
+            vc_bins_per_octave=3,
+        )
+        data_cache = endaq.batch.analyzer.CalcCache.from_raw_data(
+            [(df, ("Acceleration", "m/s\u00b2"))], calc_params
+        )
+
+        df_accel = endaq.calc.filters.butterworth(
+            df, low_cutoff=calc_params.accel_highpass_cutoff
+        )
+        pd.testing.assert_frame_equal(data_cache._accelerationData, df_accel)
+
+        (_df_accel, df_vel, df_displ) = endaq.calc.integrate.integrals(
+            df_accel,
+            n=2,
+            zero=calc_params.accel_integral_zero,
+            highpass_cutoff=calc_params.accel_highpass_cutoff,
+            tukey_percent=calc_params.accel_integral_tukey_percent,
+        )
+        pd.testing.assert_frame_equal(data_cache._velocityData, df_vel)
+        pd.testing.assert_frame_equal(data_cache._displacementData, df_displ)
+
     def test_accRMSFull(self, analyzer_bulk):
-        calc_result = endaq.batch.analyzer.Analyzer.accRMSFull.func(analyzer_bulk)[
+        calc_result = endaq.batch.analyzer.CalcCache.accRMSFull.func(analyzer_bulk)[
             "Resultant"
         ]
-        expt_result = endaq.batch.analyzer.Analyzer.MPS2_TO_G * rms(
+        expt_result = endaq.batch.analyzer.MPS2_TO_G * rms(
             analyzer_bulk._accelerationData.apply(L2_norm, axis="columns")
         )
 
         assert calc_result == pytest.approx(expt_result)
 
     def test_velRMSFull(self, analyzer_bulk):
-        calc_result = endaq.batch.analyzer.Analyzer.velRMSFull.func(analyzer_bulk)[
+        calc_result = endaq.batch.analyzer.CalcCache.velRMSFull.func(analyzer_bulk)[
             "Resultant"
         ]
-        expt_result = endaq.batch.analyzer.Analyzer.MPS_TO_MMPS * rms(
+        expt_result = endaq.batch.analyzer.MPS_TO_MMPS * rms(
             analyzer_bulk._velocityData.apply(L2_norm, axis="columns")
         )
         assert calc_result == pytest.approx(expt_result)
 
     def test_disRMSFull(self, analyzer_bulk):
-        calc_result = endaq.batch.analyzer.Analyzer.disRMSFull.func(analyzer_bulk)[
+        calc_result = endaq.batch.analyzer.CalcCache.disRMSFull.func(analyzer_bulk)[
             "Resultant"
         ]
-        expt_result = endaq.batch.analyzer.Analyzer.M_TO_MM * rms(
+        expt_result = endaq.batch.analyzer.M_TO_MM * rms(
             analyzer_bulk._displacementData.apply(L2_norm, axis="columns")
         )
         assert calc_result == pytest.approx(expt_result)
 
     def test_accPeakFull(self, analyzer_bulk):
-        calc_result = endaq.batch.analyzer.Analyzer.accPeakFull.func(analyzer_bulk)[
+        calc_result = endaq.batch.analyzer.CalcCache.accPeakFull.func(analyzer_bulk)[
             "Resultant"
         ]
-        expt_result = endaq.batch.analyzer.Analyzer.MPS2_TO_G * rms(
+        expt_result = endaq.batch.analyzer.MPS2_TO_G * rms(
             analyzer_bulk._accelerationData.apply(L2_norm, axis="columns").max()
         )
 
@@ -147,7 +250,7 @@ def test_gyroRMSFull(self, analyzer_bulk):
         pass
 
     def test_micRMSFull(self, analyzer_bulk):
-        calc_result = endaq.batch.analyzer.Analyzer.micRMSFull.func(analyzer_bulk)[
+        calc_result = endaq.batch.analyzer.CalcCache.micRMSFull.func(analyzer_bulk)[
             "Mic"
         ]
         expt_result = rms(analyzer_bulk._microphoneData)
@@ -155,7 +258,7 @@ def test_micRMSFull(self, analyzer_bulk):
         assert calc_result == pytest.approx(expt_result)
 
     def test_pressFull(self, analyzer_bulk):
-        calc_result = endaq.batch.analyzer.Analyzer.pressFull.func(analyzer_bulk)[
+        calc_result = endaq.batch.analyzer.CalcCache.pressFull.func(analyzer_bulk)[
             "Control"
         ]
         expt_result = analyzer_bulk._pressureData.mean()
@@ -163,7 +266,7 @@ def test_pressFull(self, analyzer_bulk):
         assert calc_result == pytest.approx(expt_result)
 
     def test_tempFull(self, analyzer_bulk):
-        calc_result = endaq.batch.analyzer.Analyzer.tempFull.func(analyzer_bulk)[
+        calc_result = endaq.batch.analyzer.CalcCache.tempFull.func(analyzer_bulk)[
             "Control"
         ]
         expt_result = analyzer_bulk._temperatureData.mean()
@@ -174,18 +277,23 @@ def test_tempFull(self, analyzer_bulk):
     # Live File Tests
 
     def testLiveFile(self, ide_SSX70065):
-        analyzer = endaq.batch.analyzer.Analyzer(
+        analyzer = endaq.batch.analyzer.CalcCache.from_ide(
             ide_SSX70065,
-            accel_start_time=None,
-            accel_end_time=None,
-            accel_start_margin=None,
-            accel_end_margin=None,
-            accel_highpass_cutoff=1,
-            psd_freq_bin_width=1,
-            pvss_init_freq=1,
-            pvss_bins_per_octave=12,
-            vc_init_freq=1,
-            vc_bins_per_octave=3,
+            endaq.batch.analyzer.CalcParams(
+                accel_start_time=None,
+                accel_end_time=None,
+                accel_start_margin=None,
+                accel_end_margin=None,
+                accel_highpass_cutoff=1,
+                accel_integral_tukey_percent=0,
+                accel_integral_zero="mean",
+                psd_freq_bin_width=1,
+                psd_window="hann",
+                pvss_init_freq=1,
+                pvss_bins_per_octave=12,
+                vc_init_freq=1,
+                vc_bins_per_octave=3,
+            ),
         )
 
         raw_accel = ide_SSX70065.channels[32].getSession().arrayValues()
@@ -219,18 +327,23 @@ def testLiveFile(self, ide_SSX70065):
     )
     def testLiveFiles12(self, filename):
         ds = idelib.importFile(filename)
-        analyzer = endaq.batch.analyzer.Analyzer(
+        analyzer = endaq.batch.analyzer.CalcCache.from_ide(
             ds,
-            accel_start_time=None,
-            accel_end_time=None,
-            accel_start_margin=None,
-            accel_end_margin=None,
-            accel_highpass_cutoff=1,
-            psd_freq_bin_width=1,
-            pvss_init_freq=1,
-            pvss_bins_per_octave=12,
-            vc_init_freq=1,
-            vc_bins_per_octave=3,
+            endaq.batch.analyzer.CalcParams(
+                accel_start_time=None,
+                accel_end_time=None,
+                accel_start_margin=None,
+                accel_end_margin=None,
+                accel_highpass_cutoff=1,
+                accel_integral_tukey_percent=0,
+                accel_integral_zero="mean",
+                psd_freq_bin_width=1,
+                psd_window="hann",
+                pvss_init_freq=1,
+                pvss_bins_per_octave=12,
+                vc_init_freq=1,
+                vc_bins_per_octave=3,
+            ),
         )
 
         raw_accel = ds.channels[8].getSession().arrayValues(subchannels=[0, 1, 2])
@@ -268,18 +381,23 @@ def testLiveFiles12(self, filename):
     )
     def testLiveFile3(self, filename):
         ds = idelib.importFile(filename)
-        analyzer = endaq.batch.analyzer.Analyzer(
+        analyzer = endaq.batch.analyzer.CalcCache.from_ide(
             ds,
-            accel_start_time=None,
-            accel_end_time=None,
-            accel_start_margin=None,
-            accel_end_margin=None,
-            accel_highpass_cutoff=1,
-            psd_freq_bin_width=1,
-            pvss_init_freq=1,
-            pvss_bins_per_octave=12,
-            vc_init_freq=1,
-            vc_bins_per_octave=3,
+            endaq.batch.analyzer.CalcParams(
+                accel_start_time=None,
+                accel_end_time=None,
+                accel_start_margin=None,
+                accel_end_margin=None,
+                accel_highpass_cutoff=1,
+                accel_integral_tukey_percent=0,
+                accel_integral_zero="mean",
+                psd_freq_bin_width=1,
+                psd_window="hann",
+                pvss_init_freq=1,
+                pvss_bins_per_octave=12,
+                vc_init_freq=1,
+                vc_bins_per_octave=3,
+            ),
         )
 
         ch_rot = ds.channels[84]
@@ -298,18 +416,23 @@ def testLiveFile3(self, filename):
     )
     def testLiveFileGPS(self, filename, sample_index):
         ds = idelib.importFile(filename)
-        analyzer = endaq.batch.analyzer.Analyzer(
+        analyzer = endaq.batch.analyzer.CalcCache.from_ide(
             ds,
-            accel_start_time=None,
-            accel_end_time=None,
-            accel_start_margin=None,
-            accel_end_margin=None,
-            accel_highpass_cutoff=1,
-            psd_freq_bin_width=1,
-            pvss_init_freq=1,
-            pvss_bins_per_octave=12,
-            vc_init_freq=1,
-            vc_bins_per_octave=3,
+            endaq.batch.analyzer.CalcParams(
+                accel_start_time=None,
+                accel_end_time=None,
+                accel_start_margin=None,
+                accel_end_margin=None,
+                accel_highpass_cutoff=1,
+                accel_integral_tukey_percent=0,
+                accel_integral_zero="mean",
+                psd_freq_bin_width=1,
+                psd_window="hann",
+                pvss_init_freq=1,
+                pvss_bins_per_octave=12,
+                vc_init_freq=1,
+                vc_bins_per_octave=3,
+            ),
         )
 
         ch_gps = ds.channels[88]
diff --git a/tests/batch/test_calc.py b/tests/batch/test_core.py
similarity index 87%
rename from tests/batch/test_calc.py
rename to tests/batch/test_core.py
index 7fa840c..01678d1 100644
--- a/tests/batch/test_calc.py
+++ b/tests/batch/test_core.py
@@ -2,13 +2,12 @@
 import os
 import tempfile
 
-
 import idelib
 import numpy as np
 import pandas as pd
 import pytest
 
-import endaq.batch.calc
+import endaq.batch.core
 import endaq.batch.analyzer
 from endaq.batch.utils import ide_utils
 
@@ -32,7 +31,7 @@
 )
 def test_make_meta(filename, expt_result):
     with idelib.importFile(filename) as ds:
-        meta = endaq.batch.calc._make_meta(ds)
+        meta = endaq.batch.core._make_meta(ds)
 
     assert np.all(meta.index == ["serial number", "start time"])
     assert np.all(meta == expt_result)
@@ -54,21 +53,26 @@ def test_make_peak_windows(filename):
         utc_start_time = ds.lastUtcTime
         axis_names = accel_ch.axis_names
 
-        analyzer = endaq.batch.analyzer.Analyzer(
+        analyzer = endaq.batch.analyzer.CalcCache.from_ide(
             ds,
-            accel_highpass_cutoff=None,
-            accel_start_time=None,
-            accel_end_time=None,
-            accel_start_margin=None,
-            accel_end_margin=None,
-            psd_freq_bin_width=None,
-            pvss_init_freq=None,
-            pvss_bins_per_octave=None,
-            vc_init_freq=None,
-            vc_bins_per_octave=None,
+            endaq.batch.analyzer.CalcParams(
+                accel_highpass_cutoff=None,
+                accel_start_time=None,
+                accel_end_time=None,
+                accel_start_margin=None,
+                accel_end_margin=None,
+                accel_integral_tukey_percent=0,
+                accel_integral_zero="mean",
+                psd_freq_bin_width=None,
+                psd_window="hann",
+                pvss_init_freq=None,
+                pvss_bins_per_octave=None,
+                vc_init_freq=None,
+                vc_bins_per_octave=None,
+            ),
         )
-        calc_meta = endaq.batch.calc._make_meta(ds)
-        calc_peaks = endaq.batch.calc._make_peak_windows(analyzer, margin_len=10)
+        calc_meta = endaq.batch.core._make_meta(ds)
+        calc_peaks = endaq.batch.core._make_peak_windows(analyzer, margin_len=10)
         i_max = np.argmax(np.abs(analyzer._accelerationData.to_numpy()), axis=0)
 
     assert calc_peaks.index.names == ["axis", "peak time", "peak offset"]
@@ -91,7 +95,7 @@ def test_make_peak_windows(filename):
 @pytest.fixture
 def data_builder():
     return (
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1)
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1)
         .add_psd(freq_bin_width=1)
         .add_pvss(init_freq=1, bins_per_octave=12)
         .add_metrics()
@@ -113,10 +117,12 @@ def data_builder():
         os.path.join("tests", "batch", "High-Drop.IDE"),
     ],
 )
+@pytest.mark.filterwarnings("ignore:empty frequency bins:RuntimeWarning")
+@pytest.mark.filterwarnings("ignore:no acceleration channel in:UserWarning")
 def test_get_data(filename):
     """Test `_get_data` over several varieties of recording files."""
     (
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1)
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1)
         .add_psd(freq_bin_width=1)
         .add_pvss(init_freq=1, bins_per_octave=12)
         .add_metrics()
@@ -126,9 +132,9 @@ def test_get_data(filename):
     )
 
 
-def assert_output_is_valid(output: endaq.batch.calc.OutputStruct):
+def assert_output_is_valid(output: endaq.batch.core.OutputStruct):
     """Validate the contents & structure of an `OutputStruct` object."""
-    assert isinstance(output, endaq.batch.calc.OutputStruct)
+    assert isinstance(output, endaq.batch.core.OutputStruct)
     assert isinstance(output.dataframes, dict)
     assert {
         "meta",
@@ -216,23 +222,23 @@ def assert_output_is_valid(output: endaq.batch.calc.OutputStruct):
     "getdata_builder",
     [
         # Each builder piece individually
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1),
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1).add_psd(
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1),
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1).add_psd(
             freq_bin_width=1
         ),
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1).add_pvss(
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1).add_pvss(
             init_freq=1, bins_per_octave=12
         ),
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1).add_metrics(),
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1).add_peaks(
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1).add_metrics(),
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1).add_peaks(
             margin_len=1000
         ),
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1).add_vc_curves(
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1).add_vc_curves(
             init_freq=1, bins_per_octave=3
         ),
         # All builder pieces altogether
         (
-            endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1)
+            endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1)
             .add_psd(freq_bin_width=1)
             .add_pvss(init_freq=1, bins_per_octave=12)
             .add_metrics()
@@ -240,42 +246,49 @@ def assert_output_is_valid(output: endaq.batch.calc.OutputStruct):
             .add_vc_curves(init_freq=1, bins_per_octave=3)
         ),
         # Disable highpass filter
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=None).add_psd(
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=None).add_psd(
             freq_bin_width=1
         ),
         # Test time restrictions on acceleration data
-        endaq.batch.calc.GetDataBuilder(
+        endaq.batch.core.GetDataBuilder(
             accel_highpass_cutoff=1,
             accel_start_time=np.timedelta64(5, "s"),
             accel_end_time=np.timedelta64(10, "s"),
         ).add_psd(freq_bin_width=1),
-        endaq.batch.calc.GetDataBuilder(
+        endaq.batch.core.GetDataBuilder(
             accel_highpass_cutoff=1,
             accel_start_margin=np.timedelta64(2, "s"),
             accel_end_margin=np.timedelta64(2, "s"),
         ).add_psd(freq_bin_width=1),
-        endaq.batch.calc.GetDataBuilder(
+        endaq.batch.core.GetDataBuilder(
             accel_highpass_cutoff=1,
             accel_start_time=np.timedelta64(5, "s"),
             accel_end_margin=np.timedelta64(2, "s"),
         ).add_psd(freq_bin_width=1),
-        endaq.batch.calc.GetDataBuilder(
+        endaq.batch.core.GetDataBuilder(
             accel_highpass_cutoff=1,
             accel_start_margin=np.timedelta64(2, "s"),
             accel_end_time=np.timedelta64(10, "s"),
         ).add_psd(freq_bin_width=1),
         # Octave-spaced PSD parameters
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1).add_psd(
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1).add_psd(
             bins_per_octave=1
         ),
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1).add_psd(
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1).add_psd(
             freq_start_octave=0.1, bins_per_octave=12
         ),
-        endaq.batch.calc.GetDataBuilder(accel_highpass_cutoff=1).add_psd(
+        endaq.batch.core.GetDataBuilder(accel_highpass_cutoff=1).add_psd(
             freq_bin_width=0.2, bins_per_octave=3
         ),
     ],
 )
+@pytest.mark.filterwarnings("ignore:empty frequency bins:RuntimeWarning")
+@pytest.mark.filterwarnings("ignore:no acceleration channel in:UserWarning")
+@pytest.mark.filterwarnings(
+    "ignore"
+    ":nperseg .* is greater than input length .*, using nperseg .*"
+    ":UserWarning"
+)
 def test_aggregate_data(getdata_builder):
     """Test `aggregate_data` over several configurations of `GetDataBuilder`."""
     filenames = [
@@ -580,7 +593,7 @@ def output_struct():
         columns=[fieldname_mods.get(i, i) for i in RowStruct._fields],
     )
 
-    result = endaq.batch.calc.OutputStruct(data)
+    result = endaq.batch.core.OutputStruct(data)
     assert_output_is_valid(result)
 
     return result
@@ -601,6 +614,9 @@ def test_output_to_csv_folder(output_struct):
             assert v.astype(str).compare(read_result.astype(str)).size == 0
 
 
+@pytest.mark.filterwarnings(
+    "ignore:HTML plot for metrics not currently implemented:UserWarning"
+)
 def test_output_to_html_plots(output_struct):
     with tempfile.TemporaryDirectory() as dirpath:
         output_struct.to_html_plots(folder_path=dirpath, show=False)
diff --git a/tests/calc/test_filters.py b/tests/calc/test_filters.py
index 2f9582d..fe52203 100644
--- a/tests/calc/test_filters.py
+++ b/tests/calc/test_filters.py
@@ -1,3 +1,5 @@
+import warnings
+
 import numpy as np
 import pandas as pd
 
@@ -44,7 +46,9 @@ def test_highpass():
     Fx_filt_dB = 10 * np.log10(np.abs(Fx_filt))
     assert np.allclose(Fx_filt_dB[f >= f_cut_upper], 0, atol=0.1)
 
-    f_decade = np.log10(f)
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore")
+        f_decade = np.log10(f)
     # diverges from trend at f=0
     Fx_filt_dB_diff = np.diff(Fx_filt_dB[f != 0]) / np.diff(f_decade[f != 0])
     # pre-cutoff ramps up at constant rate
diff --git a/tests/calc/test_integrate.py b/tests/calc/test_integrate.py
index 3d964c8..0a3ddb0 100644
--- a/tests/calc/test_integrate.py
+++ b/tests/calc/test_integrate.py
@@ -1,29 +1,43 @@
+import itertools
+
 import pytest
+import hypothesis as hyp
+import hypothesis.strategies as hyp_st
+import hypothesis.extra.numpy as hyp_np
+
 import numpy as np
 import pandas as pd
 import sympy as sp
 
+import endaq
 from endaq.calc import integrate
 
 
-def test_integrate():
+@hyp.given(
+    df=hyp_np.arrays(
+        elements=hyp_st.floats(-1e7, 1e7),
+        shape=(20, 2),
+        dtype=np.float64,
+    ).map(lambda array: pd.DataFrame(array, index=0.2 * np.arange(20))),
+    zero=hyp_st.sampled_from(["start", "mean", "median"]),
+)
+def test_integrate(df, zero):
     """Test `_integrate` via differentiation."""
-    n = 20
-    array = np.array([sp.symbols(f"x:{n}"), sp.symbols(f"y:{n}")]).T
-    dt = sp.symbols("dt")
+    dt = endaq.calc.sample_spacing(df)
 
     # Ensure derivative looks correct
-    calc_result = integrate._integrate(
-        pd.DataFrame(array, index=dt * np.arange(len(array)))
-    )
-    expt_result_diff = 0.5 * dt * (array[:-1] + array[1:])
-    assert np.all(np.diff(calc_result, axis=0) - expt_result_diff == 0)
+    calc_result = integrate._integrate(df, zero)
+    expt_result_diff = 0.5 * dt * (df.to_numpy()[:-1] + df.to_numpy()[1:])
+    assert np.diff(calc_result.to_numpy(), axis=0) == pytest.approx(expt_result_diff)
 
     # Ensure offset results in zero-mean data
     # Note: symbols cannot be directly tested, since scalar factors are floats
-    assert calc_result.to_numpy().mean().subs(
-        [(dt, 1)] + list(zip(array.flatten(), np.ones(array.size)))
-    ) == pytest.approx(0)
+    zero_quantity = {
+        "start": calc_result.iloc[0],
+        "mean": calc_result.mean(),
+        "median": calc_result.median(),
+    }[zero]
+    np.testing.assert_allclose(zero_quantity, 0, atol=1e-7 * df.abs().mean().mean())
 
 
 def test_integrals():
@@ -38,3 +52,22 @@ def test_integrals():
     assert np.all(calc_result[0] == df)
     for dx_dt, x in zip(calc_result[:-1], calc_result[1:]):
         assert np.all(x == integrate._integrate(dx_dt))
+
+
+@hyp.given(
+    df=hyp_np.arrays(
+        elements=hyp_st.floats(-1e7, 1e7),
+        shape=(20, 2),
+        dtype=np.float64,
+    ).map(lambda array: pd.DataFrame(array, index=0.2 * np.arange(20))),
+    zero=hyp_st.sampled_from(["start", "mean", "median"]),
+    highpass_cutoff=hyp_st.one_of(hyp_st.floats(0.25, 1), hyp_st.just(None)),
+    tukey_percent=hyp_st.floats(0, 0.2),
+)
+def test_integrals_iter_vs_list(df, **kwargs):
+    n = 3
+    result1 = list(itertools.islice(integrate.iter_integrals(df, **kwargs), 2))
+    result2 = integrate.integrals(df, n, **kwargs)
+
+    for r1, r2 in zip(result1, result2):
+        pd.testing.assert_frame_equal(r1, r2)
diff --git a/tests/calc/test_psd.py b/tests/calc/test_psd.py
index 7a1ff98..13c003c 100644
--- a/tests/calc/test_psd.py
+++ b/tests/calc/test_psd.py
@@ -1,5 +1,7 @@
 from collections import namedtuple
+import pathlib
 import timeit
+import textwrap
 
 import pytest
 import hypothesis as hyp
@@ -9,7 +11,7 @@
 import numpy as np
 import pandas as pd
 
-from endaq.calc import psd, stats
+from endaq.calc import psd, stats, utils
 
 
 @hyp.given(
@@ -39,26 +41,6 @@ def test_welch_parseval(df):
     assert df_psd.to_numpy().sum() == pytest.approx(stats.rms(df.to_numpy()) ** 2)
 
 
-@pytest.mark.parametrize(
-    "agg1, agg2",
-    [
-        ("mean", lambda x, axis=-1: np.nan_to_num(np.mean(x, axis=axis))),
-        ("sum", np.sum),
-    ],
-)
-def test_to_jagged_modes(psd_df, freq_splits, agg1, agg2):
-    """Test `to_jagged(..., mode='mean')` against the equivalent `mode=np.mean`."""
-    result1 = psd.to_jagged(psd_df, freq_splits, agg=agg1)
-    result2 = psd.to_jagged(psd_df, freq_splits, agg=agg2)
-
-    assert np.all(result1.index == result2.index)
-    np.testing.assert_allclose(
-        result1.to_numpy(),
-        result2.to_numpy(),
-        atol=psd_df.min().min() * 1e-7,
-    )
-
-
 @hyp.given(
     psd_df=hyp_np.arrays(
         dtype=np.float64,
@@ -79,6 +61,7 @@ def test_to_jagged_modes(psd_df, freq_splits, agg1, agg2):
         ("sum", np.sum),
     ],
 )
+@pytest.mark.filterwarnings("ignore:empty frequency bins:RuntimeWarning")
 def test_to_jagged_modes(psd_df, freq_splits, agg1, agg2):
     """Test `to_jagged(..., mode='mean')` against the equivalent `mode=np.mean`."""
     result1 = psd.to_jagged(psd_df, freq_splits, agg=agg1)
@@ -100,20 +83,22 @@ def test_to_jagged_mode_times():
     Check that a situation exists where the histogram method is more
     performant.
     """
-    setup = """
-from endaq.calc import psd
-import numpy as np
-import pandas as pd
-
-n = 10 ** 4
-
-axis = -1
-psd_array = np.random.random((3, n))
-f = np.arange(n) / 3
-psd_df = pd.DataFrame(psd_array.T, index=f)
-#freq_splits = np.logspace(0, np.log2(n), num=100, base=2)
-freq_splits = f[1:-1]
-    """
+    setup = textwrap.dedent(
+        """
+        from endaq.calc import psd
+        import numpy as np
+        import pandas as pd
+
+        n = 10 ** 4
+
+        axis = -1
+        psd_array = np.random.random((3, n))
+        f = np.arange(n) / 3
+        psd_df = pd.DataFrame(psd_array.T, index=f)
+        #freq_splits = np.logspace(0, np.log2(n), num=100, base=2)
+        freq_splits = f[1:-1]
+        """
+    )
 
     t_direct = timeit.timeit(
         "psd.to_jagged(psd_df, freq_splits, agg=np.sum)",
@@ -169,6 +154,7 @@ def test_to_jagged_mode_times():
         ),
     ],
 )
+@pytest.mark.filterwarnings("ignore:empty frequency bins:RuntimeWarning")
 def test_to_octave(psd_df, agg, expt_f, expt_array):
     calc_df = psd.to_octave(psd_df, fstart=1, octave_bins=1, agg=agg)
     assert calc_df.index.to_numpy().tolist() == expt_f
diff --git a/tests/calc/test_shock.py b/tests/calc/test_shock.py
index d4df33e..02833ab 100644
--- a/tests/calc/test_shock.py
+++ b/tests/calc/test_shock.py
@@ -1,5 +1,8 @@
 # -*- coding: utf-8 -*-
 
+from unittest import mock
+import warnings
+
 import pytest
 import hypothesis as hyp
 import hypothesis.strategies as hyp_st
@@ -246,25 +249,57 @@ def test_pseudo_velocity_zero_padding(
     ).map(lambda array: pd.DataFrame(array, index=np.arange(1, 41))),
     damp=hyp_st.floats(0, 0.2),
 )
-@hyp.settings(deadline=None)  # this test tends to timeout
 def test_enveloping_half_sine(df_pvss, damp):
-    ampl, T = shock.enveloping_half_sine(df_pvss, damp=damp)
-    hyp.note(f"pulse amplitude: {ampl}")
-    hyp.note(f"pulse duration: {T}")
-
-    ampl = ampl[0]
-    T = T[0]
-
-    dt = min(
-        1 / (2 * df_pvss.index[-1]), T / 20
-    )  # guarantee sufficient # of samples to represent pulse
-    fs = 1 / dt
-    times = np.arange(int(fs * (T + 1 / df_pvss.index[0]))) / fs
-    pulse = np.zeros_like(times)
-    pulse[: int(T * fs)] = ampl * np.sin((np.pi / T) * times[: int(T * fs)])
+    env_half_sine = shock.enveloping_half_sine(df_pvss, damp=damp)
+    hyp.note(f"pulse amplitude: {env_half_sine.amplitude}")
+    hyp.note(f"pulse duration: {env_half_sine.duration}")
+
+    pulse = env_half_sine.to_time_series()
     pulse_pvss = shock.shock_spectrum(
-        pd.DataFrame(pulse, index=times), freqs=df_pvss.index, damp=damp, mode="pvss"
+        pulse, freqs=df_pvss.index, damp=damp, mode="pvss"
     )
 
     # This is an approximation -> give the result a fudge-factor for correctness
     assert (df_pvss / pulse_pvss).max().max() < 1.2
+
+
+class TestHalfSineWavePulse:
+    @pytest.mark.parametrize(
+        "tstart, tstop, dt, tpulse, warning_type",
+        [
+            # dt warnings
+            (None, None, 0.12, 0, None),  # dt < duration / 8 => OK
+            (None, None, 0.13, 0, UserWarning),  # dt > duration / 8 => WARNING
+            # trange warnings
+            (None, 0.5, None, 0, UserWarning),  # trange[1] < t0 + duration => WARNING
+            (0.5, None, None, 0, UserWarning),  # trange[0] > t0 => WARNING
+            (0.5, None, None, 1, None),  # OK
+        ],
+    )
+    def test_to_time_series_warnings(self, tstart, tstop, dt, tpulse, warning_type):
+        env_half_sine = shock.HalfSineWavePulse(
+            amplitude=pd.Series([1]),
+            duration=pd.Series([1]),
+        )
+
+        if warning_type is None:
+            with warnings.catch_warnings():
+                warnings.simplefilter("error")
+                env_half_sine.to_time_series(
+                    tstart=tstart, tstop=tstop, dt=dt, tpulse=tpulse
+                )
+        else:
+            with pytest.warns(warning_type):
+                env_half_sine.to_time_series(
+                    tstart=tstart, tstop=tstop, dt=dt, tpulse=tpulse
+                )
+
+    def test_tuple_like(self):
+        env_half_sine = shock.HalfSineWavePulse(
+            amplitude=mock.sentinel.amplitude,
+            duration=mock.sentinel.duration,
+        )
+
+        ampl, T = env_half_sine
+        assert ampl == mock.sentinel.amplitude
+        assert T == mock.sentinel.duration
diff --git a/tests/calc/test_utils.py b/tests/calc/test_utils.py
index 4b6abb0..60b42af 100644
--- a/tests/calc/test_utils.py
+++ b/tests/calc/test_utils.py
@@ -23,6 +23,7 @@ def logfreq_input(draw):
 
 
 @hyp.given(input_vars=logfreq_input())
+@pytest.mark.filterwarnings("ignore:the data's duration is too short:RuntimeWarning")
 def test_logfreqs(input_vars):
     (dframe, init_freq, bins_per_octave) = input_vars
 
@@ -57,3 +58,12 @@ def test_to_dB_scale(value, reference, squared):
     assert utils.to_dB(10 * value, reference, squared) == pytest.approx(
         scale + utils.to_dB(value, reference, squared)
     )
+
+
+def test_uniform_resample_is_uniform_for_datetime64ns():
+    """
+    Tests if the resample function for np.datetime64[ns] dtype arrays will produce non-uniform timestamps
+    """
+    times = np.arange('2021-02', '2021-03', np.timedelta64(1, 's'), dtype='datetime64[ns]')
+    df = pd.DataFrame(np.arange(len(times)), index=times)
+    assert len(np.unique(np.diff(utils.resample(df).index))) == 1
diff --git a/tests/ide/test_info.py b/tests/ide/test_info.py
index d50ecc8..29ad722 100644
--- a/tests/ide/test_info.py
+++ b/tests/ide/test_info.py
@@ -59,7 +59,8 @@ def test_format_channel_id(self):
         self.assertEqual(info.format_channel_id(dataset.channels[59]), '59.*')
         self.assertEqual(info.format_channel_id(dataset.channels[59][0]), '59.0')
 
-        self.assertEqual(info.format_channel_id(None), "None")
+        with pytest.warns(UserWarning):
+            self.assertEqual(info.format_channel_id(None), "None")
 
 
     def test_format_timedelta(self):
@@ -73,7 +74,8 @@ def test_format_timedelta(self):
 
         # Number instead of timedelta. Unlikely but not not impossible.
         self.assertTrue(info.format_timedelta(100000000).startswith('01:40.'))
-        self.assertEqual(info.format_timedelta(None), "None")
+        with pytest.warns(UserWarning):
+            self.assertEqual(info.format_timedelta(None), "None")
 
 
     def test_format_timestamp(self):
@@ -81,8 +83,10 @@ def test_format_timestamp(self):
             self.assertTrue(info.format_timestamp(i).startswith(str(i)))
             self.assertTrue(info.format_timestamp(str(i)).startswith(str(i)))
 
-        self.assertEqual(info.format_timestamp('bogus'), 'bogus')
-        self.assertEqual(info.format_timestamp(None), "None")
+        with pytest.warns(UserWarning):
+            self.assertEqual(info.format_timestamp('bogus'), 'bogus')
+        with pytest.warns(UserWarning):
+            self.assertEqual(info.format_timestamp(None), "None")
 
 
 class ChannelTableTests(unittest.TestCase):